如何创建交互式脑形图?

How to create an interactive brain-shaped graph?

我正在 networkxplotly 从事可视化项目。有没有一种方法可以创建类似于人脑在 networkx 中的样子的 3D 图形,然后用 plotly 对其进行可视化(因此它将是交互式的)?

我们的想法是让节点在外面(或者只显示节点,如果这样更容易的话)并像上图那样用不同的颜色给一组节点上色

首先,此代码大量 借自 Matteo Mancini,他描述了 here and he has released under the MIT license

在原始代码中,没有使用 networkx,因此很明显您实际上不需要 networkx 来实现您的目标。如果这不是一个严格的要求,我会考虑使用他的原始代码并对其进行修改以适合您的输入数据。

由于您将 networkx 列为要求,我只是修改了他的代码以获取具有某些节点属性(例如 'color''coord' 的 networkx Graph 对象用于那些最终情节散点图中的标记特征。我只是选择了数据集中的前十个点来着色为红色,这就是它们没有分组的原因。

完整的可复制粘贴代码如下。此处的屏幕截图显然不是交互式的,但您可以尝试演示 here on Google Colab.


如果在 Linux/Mac 上的 Jupyter notebook 中下载文件:

!wget https://github.com/matteomancini/neurosnippets/raw/master/brainviz/interactive-network/lh.pial.obj
!wget https://github.com/matteomancini/neurosnippets/raw/master/brainviz/interactive-network/icbm_fiber_mat.txt
!wget https://github.com/matteomancini/neurosnippets/raw/master/brainviz/interactive-network/fs_region_centers_68_sort.txt
!wget https://github.com/matteomancini/neurosnippets/raw/master/brainviz/interactive-network/freesurfer_regions_68_sort_full.txt
  • 否则:下载需要的文件here.

代码:

import numpy as np
import plotly.graph_objects as go
import networkx as nx # New dependency


def obj_data_to_mesh3d(odata):
    # odata is the string read from an obj file
    vertices = []
    faces = []
    lines = odata.splitlines()   
   
    for line in lines:
        slist = line.split()
        if slist:
            if slist[0] == 'v':
                vertex = np.array(slist[1:], dtype=float)
                vertices.append(vertex)
            elif slist[0] == 'f':
                face = []
                for k in range(1, len(slist)):
                    face.append([int(s) for s in slist[k].replace('//','/').split('/')])
                if len(face) > 3: # triangulate the n-polyonal face, n>3
                    faces.extend([[face[0][0]-1, face[k][0]-1, face[k+1][0]-1] for k in range(1, len(face)-1)])
                else:    
                    faces.append([face[j][0]-1 for j in range(len(face))])
            else: pass
    
    
    return np.array(vertices), np.array(faces)


with open("lh.pial.obj", "r") as f:
    obj_data = f.read()
[vertices, faces] = obj_data_to_mesh3d(obj_data)

vert_x, vert_y, vert_z = vertices[:,:3].T
face_i, face_j, face_k = faces.T

cmat = np.loadtxt('icbm_fiber_mat.txt')
nodes = np.loadtxt('fs_region_centers_68_sort.txt')

labels=[]
with open("freesurfer_regions_68_sort_full.txt", "r") as f:
    for line in f:
        labels.append(line.strip('\n'))

# Instantiate Graph and add nodes (with their coordinates)
G = nx.Graph()

for idx, node in enumerate(nodes):
    G.add_node(idx, coord=node)

# Add made-up colors for the nodes as node attribute
colors_data = {node: ('gray' if node > 10 else 'red') for node in G.nodes}
nx.set_node_attributes(G, colors_data, name="color")

# Add edges
[source, target] = np.nonzero(np.triu(cmat)>0.01)
edges = list(zip(source, target))

G.add_edges_from(edges)

# Get node coordinates from node attribute
nodes_x = [data['coord'][0] for node, data in G.nodes(data=True)]
nodes_y = [data['coord'][1] for node, data in G.nodes(data=True)]
nodes_z = [data['coord'][2] for node, data in G.nodes(data=True)]

edge_x = []
edge_y = []
edge_z = []
for s, t in edges:
    edge_x += [nodes_x[s], nodes_x[t]]
    edge_y += [nodes_y[s], nodes_y[t]]
    edge_z += [nodes_z[s], nodes_z[t]]

# Get node colors from node attribute
node_colors = [data['color'] for node, data in G.nodes(data=True)]

fig = go.Figure()

# Changed color and opacity kwargs
fig.add_trace(go.Mesh3d(x=vert_x, y=vert_y, z=vert_z, i=face_i, j=face_j, k=face_k,
                        color='gray', opacity=0.1, name='', showscale=False, hoverinfo='none'))

fig.add_trace(go.Scatter3d(x=nodes_x, y=nodes_y, z=nodes_z, text=labels,
                           mode='markers', hoverinfo='text', name='Nodes',
                           marker=dict(
                                       size=5, # Changed node size...
                                       color=node_colors # ...and color
                                      )
                           ))
fig.add_trace(go.Scatter3d(x=edge_x, y=edge_y, z=edge_z,
                           mode='lines', hoverinfo='none', name='Edges',
                           opacity=0.3, # Added opacity kwarg
                           line=dict(color='pink') # Added line color
                           ))

fig.update_layout(
    scene=dict(
        xaxis=dict(showticklabels=False, visible=False),
        yaxis=dict(showticklabels=False, visible=False),
        zaxis=dict(showticklabels=False, visible=False),
    ),
    width=800, height=600
)

fig.show()

Vispy 库可能会有用 https://github.com/vispy/vispy。 我认为您可以使用以下示例。

3D brain mesh viewer

1ex output

Plot various views of a structural MRI.

2ex output

Clipping planes with volume and markers

3ex output

这些示例是交互式的。

此致!

一种可行的方法:

import networkx as nx
import random
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from stl import mesh

# function to convert stl 3d-model to mesh 
# Taken from : https://chart-studio.plotly.com/~empet/15276/converting-a-stl-mesh-to-plotly-gomes/#/

def stl2mesh3d(stl_mesh):
    # stl_mesh is read by nympy-stl from a stl file; it is  an array of faces/triangles (i.e. three 3d points) 
    # this function extracts the unique vertices and the lists I, J, K to define a Plotly mesh3d
    p, q, r = stl_mesh.vectors.shape #(p, 3, 3)
    # the array stl_mesh.vectors.reshape(p*q, r) can contain multiple copies of the same vertex;
    # extract unique vertices from all mesh triangles
    vertices, ixr = np.unique(stl_mesh.vectors.reshape(p*q, r), return_inverse=True, axis=0)
    I = np.take(ixr, [3*k for k in range(p)])
    J = np.take(ixr, [3*k+1 for k in range(p)])
    K = np.take(ixr, [3*k+2 for k in range(p)])
    return vertices, I, J, K

# Let's use a toy "brain" stl file. You can get it from my Dropbox: https://www.dropbox.com/s/lav2opci8vekaep/brain.stl?dl=0
#
# Note: I made it quick and dirty whith Blender and is not supposed to be an accurate representation 
# of an actual brain. You can put your own model here.

my_mesh = mesh.Mesh.from_file('brain.stl')
vertices, I, J, K = stl2mesh3d(my_mesh)
x, y, z = vertices.T    # x,y,z contain the stl vertices


# Let's generate a random spatial graph:
# Note: spatial graphs have a "pos" (position) attribute
# pos = nx.get_node_attributes(G, "pos")

G = nx.random_geometric_graph(30, 0.3, dim=3)  # in dimension 3 --> pos = [x,y,z]

#nx.draw(G)
print('Nb. of nodes: ',G.number_of_nodes(), 'Nb. of edges: ',G.number_of_edges())

# Take G.number_of_nodes() of nodes and attribute them randomly to points in the list of vertices of the STL model:
# That is, we "scatter" the nodes on the brain surface:

Vec3dList=list(np.array(random.sample(list(vertices), G.number_of_nodes())))

for i in range(len(Vec3dList)):
    G.nodes[i]['pos']=Vec3dList[i]


# Create nodes and edges graph objects:
# Code from: https://plotly.com/python/network-graphs/  modified to work with 3d graphs
edge_x = []
edge_y = []
edge_z = []
for edge in G.edges():
    x0, y0, z0 = G.nodes[edge[0]]['pos']
    x1, y1, z1 = G.nodes[edge[1]]['pos']
    edge_x.append(x0)
    edge_x.append(x1)
    edge_x.append(None)
    edge_y.append(y0)
    edge_y.append(y1)
    edge_y.append(None)
    edge_z.append(z0)
    edge_z.append(z1)
    edge_z.append(None)
    

edge_trace = go.Scatter3d(
    x=edge_x, y=edge_y, z=edge_z,
    line=dict(width=2, color='#888'),
    hoverinfo='none',
    opacity=.3,
    mode='lines')

node_x = []
node_y = []
node_z = []
for node in G.nodes():
    X, Y, Z = G.nodes[node]['pos']
    node_x.append(X)
    node_y.append(Y)
    node_z.append(Z)

node_trace = go.Scatter3d(
    x=node_x, y=node_y,z=node_z,
    mode='markers',
    hoverinfo='text',
    marker=dict(
        showscale=True,
        # colorscale options
        #'Greys' | 'YlGnBu' | 'Greens' | 'YlOrRd' | 'Bluered' | 'RdBu' |
        #'Reds' | 'Blues' | 'Picnic' | 'Rainbow' | 'Portland' | 'Jet' |
        #'Hot' | 'Blackbody' | 'Earth' | 'Electric' | 'Viridis' |
        colorscale='YlGnBu',
        reversescale=True,
        color=[],
        size=5,
        colorbar=dict(
            thickness=15,
            title='Node Connections',
            xanchor='left',
            titleside='right'
        ),
        line_width=10))

node_adjacencies = []
node_text = []
for node, adjacencies in enumerate(G.adjacency()):
    node_adjacencies.append(len(adjacencies[1]))
    node_text.append('# of connections: '+str(len(adjacencies[1])))

node_trace.marker.color = node_adjacencies
node_trace.text = node_text

colorscale= [[0, '#e5dee5'], [1, '#e5dee5']]                           
mesh3D = go.Mesh3d(
            x=x,
            y=y,
            z=z, 
            i=I, 
            j=J, 
            k=K, 
            flatshading=False,
            colorscale=colorscale, 
            intensity=z, 
            name='Brain',
            opacity=0.25,
            hoverinfo='none',
            showscale=False)


title = "Brain"
layout = go.Layout(paper_bgcolor='rgb(1,1,1)',
            title_text=title, title_x=0.5,
                   font_color='white',
            width=800,
            height=800,
            scene_camera=dict(eye=dict(x=1.25, y=-1.25, z=1)),
            scene_xaxis_visible=False,
            scene_yaxis_visible=False,
            scene_zaxis_visible=False)



fig = go.Figure(data=[mesh3D, edge_trace, node_trace], layout=layout)

fig.data[0].update(lighting=dict(ambient= .2,
                                 diffuse= 1,
                                 fresnel=  1,
                                 specular= 1,
                                 roughness= .1,
                                 facenormalsepsilon=0))
                                 
fig.data[0].update(lightposition=dict(x=3000,
                                      y=3000,
                                      z=10000));

fig.show()

下面是结果。如您所见,结果并不是那么好……但是,也许您可​​以改进它。 最好的祝福

根据明确的要求,我采取了新的做法:

  1. BrainNet Viewer github repo;
  2. 下载准确的大脑网格数据
  3. 使用 Kamada-Kuwai cost function 在以包含大脑网格的球体为中心的三个维度中绘制具有 3D 坐标的随机图;
  4. 将节点位置从脑网格中心径向扩展,然后将它们移回脑网格上实际最近的顶点;
  5. 根据与随机选择的网格顶点的任意距离标准将一些节点着色为红色;
  6. Fiddle 加上一堆绘图参数让它看起来不错。

有一个明确划定的点可以添加不同的图形数据以及更改决定节点颜色的逻辑。在引入新的图形数据后要使用的关键参数是:

  • scale_factor:这会改变原始 Kamada-Kuwai 计算的坐标在返回其表面之前从大脑网格中心径向移动的量。较大的值将使更多节点捕捉到大脑的外表面。值越小,两个半球之间的表面上的节点就越多。
  • opacity 边缘轨迹中的线条:具有更多边缘的图形会很快使视野变得混乱,并使整体大脑形状不那么明显。这说明了我对这种整体方法最大的不满——出现在网格表面之外的边缘使得更难看到网格的整体形状,尤其是在颞叶之间。

我在这里的另一个最大的警告是,没有尝试检查位于大脑表面的任何节点是否碰巧 重合 或具有任何相等的间距。

这是屏幕截图和 live demo on Colab。下面是完整的可复制粘贴代码。

这里可以讨论一大堆旁白,但为了简洁起见,我只记下两个:

  1. 对此主题感兴趣但对编程细节感到不知所措的人绝对应该查看 BrainNet Viewer;
  2. BrainNet Viewer github repo 中还有许多其他脑网可以使用。更好的是,如果您有任何可以格式化或重新设计以与这种方法兼容的网格,您至少可以 尝试将一组节点包裹在任何其他非大脑和有点圆形的网格周围,代表任何其他对象.

import plotly.graph_objects as go
import numpy as np
import networkx as nx
import math

    
def mesh_properties(mesh_coords):
    """Calculate center and radius of sphere minimally containing a 3-D mesh
    
    Parameters
    ----------
    mesh_coords : tuple
        3-tuple with x-, y-, and z-coordinates (respectively) of 3-D mesh vertices
    """

    radii = []
    center = []

    for coords in mesh_coords:
        c_max = max(c for c in coords)
        c_min = min(c for c in coords)
        center.append((c_max + c_min) / 2)

        radius = (c_max - c_min) / 2
        radii.append(radius)

    return(center, max(radii))


# Download and prepare dataset from BrainNet repo
coords = np.loadtxt(np.DataSource().open('https://raw.githubusercontent.com/mingruixia/BrainNet-Viewer/master/Data/SurfTemplate/BrainMesh_Ch2_smoothed.nv'), skiprows=1, max_rows=53469)
x, y, z = coords.T

triangles = np.loadtxt(np.DataSource().open('https://raw.githubusercontent.com/mingruixia/BrainNet-Viewer/master/Data/SurfTemplate/BrainMesh_Ch2_smoothed.nv'), skiprows=53471, dtype=int)
triangles_zero_offset = triangles - 1
i, j, k = triangles_zero_offset.T

# Generate 3D mesh.  Simply replace with 'fig = go.Figure()' or turn opacity to zero if seeing brain mesh is not desired.
fig = go.Figure(data=[go.Mesh3d(x=x, y=y, z=z,
                                 i=i, j=j, k=k,
                                 color='lightpink', opacity=0.5, name='', showscale=False, hoverinfo='none')])

# Generate networkx graph and initial 3-D positions using Kamada-Kawai path-length cost-function inside sphere containing brain mesh
G = nx.gnp_random_graph(200, 0.02, seed=42) # Replace G with desired graph here

mesh_coords = (x, y, z)
mesh_center, mesh_radius = mesh_properties(mesh_coords)

scale_factor = 5 # Tune this value by hand to have more/fewer points between the brain hemispheres.
pos_3d = nx.kamada_kawai_layout(G, dim=3, center=mesh_center, scale=scale_factor*mesh_radius) 

# Calculate final node positions on brain surface
pos_brain = {}

for node, position in pos_3d.items():
    squared_dist_matrix = np.sum((coords - position) ** 2, axis=1)
    pos_brain[node] = coords[np.argmin(squared_dist_matrix)]

# Prepare networkx graph positions for plotly node and edge traces
nodes_x = [position[0] for position in pos_brain.values()]
nodes_y = [position[1] for position in pos_brain.values()]
nodes_z = [position[2] for position in pos_brain.values()]

edge_x = []
edge_y = []
edge_z = []
for s, t in G.edges():
    edge_x += [nodes_x[s], nodes_x[t]]
    edge_y += [nodes_y[s], nodes_y[t]]
    edge_z += [nodes_z[s], nodes_z[t]]

# Decide some more meaningful logic for coloring certain nodes.  Currently the squared distance from the mesh point at index 42.
node_colors = []
for node in G.nodes():
    if np.sum((pos_brain[node] - coords[42]) ** 2) < 1000:
        node_colors.append('red')
    else:
        node_colors.append('gray')

# Add node plotly trace
fig.add_trace(go.Scatter3d(x=nodes_x, y=nodes_y, z=nodes_z,
                           #text=labels,
                           mode='markers', 
                           #hoverinfo='text',
                           name='Nodes',
                           marker=dict(
                                       size=5,
                                       color=node_colors
                                      )
                           ))

# Add edge plotly trace.  Comment out or turn opacity to zero if not desired.
fig.add_trace(go.Scatter3d(x=edge_x, y=edge_y, z=edge_z,
                           mode='lines',
                           hoverinfo='none',
                           name='Edges',
                           opacity=0.1, 
                           line=dict(color='gray')
                           ))

# Make axes invisible
fig.update_scenes(xaxis_visible=False,
                  yaxis_visible=False,
                  zaxis_visible=False)

# Manually adjust size of figure
fig.update_layout(autosize=False,
                  width=800,
                  height=800)

fig.show()