Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create an interactive brain-shaped graph?

I'm working on a visualization project in networkx and plotly. Is there a way to create a 3D graph that resembles how a human brain looks like in networkx and then to visualize it with plotly (so it will be interactive)? enter image description here

The idea is to have the nodes on the outside (or only show the nodes if it's easier) and to color a set of them differently like the image above

like image 834
Penguin Avatar asked Nov 02 '21 03:11

Penguin


2 Answers

Based on the clarified requirements, I took a new approach:

  1. Download accurate brain mesh data from BrainNet Viewer github repo;
  2. Plot a random graph with 3D-coordinates using Kamada-Kuwai cost function in three dimensions centered in a sphere containing the brain mesh;
  3. Radially expand the node positions away from the center of the brain mesh and then shift them back to the closest vertex actually on the brain mesh;
  4. Color some nodes red based on an arbitrary distance criterion from a randomly selected mesh vertex;
  5. Fiddle with a bunch of plotting parameters to make it look decent.

There is a clearly delineated spot to add in different graph data as well as change the logic by which the node colors are decided. The key parameters to play with so that things look decent after introducing new graph data are:

  • scale_factor: This changes how much the original Kamada-Kuwai calculated coordinates are translated radially away from the center of the brain mesh before they are snapped back to its surface. Larger values will make more nodes snap to the outer surface of the brain. Smaller values will leave more nodes positioned on the surfaces between the two hemispheres.
  • opacity of the lines in the edge trace: Graphs with more edges will quickly clutter up field of view and make the overall brain shape less visible. This speaks to my biggest dissatisfaction with this overall approach -- that edges which appear outside of the mesh surface make it harder to see the overall shape of the mesh, especially between the temporal lobes.

My other biggest caveat here is that there is no attempt has been made to check whether any nodes positioned on the brain surface happen to coincide or have any sort of equal spacing.

Here is a screenshot and the live demo on Colab. Full copy-pasteable code below.

brain_network_on_surface

There are a whole bunch of asides that could be discussed here, but for brevity I will only note two:

  1. Folks interested in this topic but feeling overwhelmed by programming details should absolutely check out BrainNet Viewer;
  2. There are plenty of other brain meshes in the BrainNet Viewer github repo that could be used. Even better, if you have any mesh which can be formatted or reworked to be compatible with this approach, you could at least try wrapping a set of nodes around any other non-brain and somewhat round-ish mesh representing any other object.

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()
like image 122
Frodnar Avatar answered Oct 14 '22 01:10

Frodnar


A possible way to do that:

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()

Below, the result. As you can see the result is not that great... But, maybe, you can improve on it. Best regards enter image description here

like image 45
S_Bersier Avatar answered Oct 14 '22 00:10

S_Bersier