bsplot 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. bsplot/__init__.py +23 -0
  2. bsplot/anat.py +55 -0
  3. bsplot/animate.py +194 -0
  4. bsplot/bioicons.py +777 -0
  5. bsplot/brain.py +188 -0
  6. bsplot/colors.py +1935 -0
  7. bsplot/data/HCP_avg-SC.txt +379 -0
  8. bsplot/data/MNI152.rh.pial +0 -0
  9. bsplot/data/STN_lh.nii.gz +0 -0
  10. bsplot/data/STN_rh.nii.gz +0 -0
  11. bsplot/data/__init__.py +22 -0
  12. bsplot/data/left_electrode.ply +257270 -0
  13. bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
  14. bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
  15. bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
  16. bsplot/data/right_electrode.ply +257270 -0
  17. bsplot/data/surface.py +112 -0
  18. bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
  19. bsplot/figure.py +177 -0
  20. bsplot/graph/__init__.py +31 -0
  21. bsplot/graph/edges.py +312 -0
  22. bsplot/graph/flowchart.py +762 -0
  23. bsplot/graph/layout.py +201 -0
  24. bsplot/graph/network.py +974 -0
  25. bsplot/graph/nodes.py +452 -0
  26. bsplot/panels.py +110 -0
  27. bsplot/scientific_color_maps/__init__.py +0 -0
  28. bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
  29. bsplot/scientific_color_maps/batlowK.txt +256 -0
  30. bsplot/scientific_color_maps/batlowW.txt +256 -0
  31. bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
  32. bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
  33. bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
  34. bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
  35. bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
  36. bsplot/scientific_color_maps/diverging/bam.txt +256 -0
  37. bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
  38. bsplot/scientific_color_maps/diverging/broc.txt +256 -0
  39. bsplot/scientific_color_maps/diverging/cork.txt +256 -0
  40. bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
  41. bsplot/scientific_color_maps/diverging/managua.txt +256 -0
  42. bsplot/scientific_color_maps/diverging/roma.txt +256 -0
  43. bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
  44. bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
  45. bsplot/scientific_color_maps/diverging/vik.txt +256 -0
  46. bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
  47. bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
  48. bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
  49. bsplot/scientific_color_maps/naviaW.txt +256 -0
  50. bsplot/scientific_color_maps/sequential/acton.txt +256 -0
  51. bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
  52. bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
  53. bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
  54. bsplot/scientific_color_maps/sequential/buda.txt +256 -0
  55. bsplot/scientific_color_maps/sequential/davos.txt +256 -0
  56. bsplot/scientific_color_maps/sequential/devon.txt +256 -0
  57. bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
  58. bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
  59. bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
  60. bsplot/scientific_color_maps/sequential/imola.txt +256 -0
  61. bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
  62. bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
  63. bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
  64. bsplot/scientific_color_maps/sequential/navia.txt +256 -0
  65. bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
  66. bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
  67. bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
  68. bsplot/scientific_color_maps/sequential/turku.txt +256 -0
  69. bsplot/streamlines.py +314 -0
  70. bsplot/style.py +791 -0
  71. bsplot/styles/black.mplstyle +78 -0
  72. bsplot/styles/bss.mplstyle +50 -0
  73. bsplot/styles/bwcomp.mplstyle +30 -0
  74. bsplot/styles/nature.mplstyle +57 -0
  75. bsplot/styles/transparent.mplstyle +16 -0
  76. bsplot/styles/tvbo.mplstyle +57 -0
  77. bsplot/surface.py +2370 -0
  78. bsplot/templates.py +270 -0
  79. bsplot/text.py +199 -0
  80. bsplot/text2obj.py +59 -0
  81. bsplot/timeseries.py +10 -0
  82. bsplot/utils.py +13 -0
  83. bsplot/volume.py +958 -0
  84. bsplot-0.0.2.dist-info/METADATA +60 -0
  85. bsplot-0.0.2.dist-info/RECORD +89 -0
  86. bsplot-0.0.2.dist-info/WHEEL +5 -0
  87. bsplot-0.0.2.dist-info/entry_points.txt +2 -0
  88. bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
  89. bsplot-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,974 @@
1
+ import numpy as np
2
+ import networkx as nx
3
+ import matplotlib.pyplot as plt
4
+ import nibabel
5
+ import bsplot
6
+ from bsplot.colors import Colormap as _resolve_cmap
7
+ from bsplot.data.surface import get_surface_geometry
8
+ from bsplot import data
9
+
10
+
11
+ def _normalize_scale_values(raw_values, mode="linear"):
12
+ """Normalize values to [0, 1] for mesh radius scaling.
13
+
14
+ Uses percentile clipping for linear/log/exp modes to reduce outlier
15
+ dominance. "quantile" mode maps values to rank percentiles directly,
16
+ which is useful for dense graphs where geometric contrast is subtle.
17
+ """
18
+ values = np.asarray(raw_values, dtype=float)
19
+ if values.size == 0:
20
+ return values
21
+
22
+ if values.size == 1:
23
+ return np.array([0.5], dtype=float)
24
+
25
+ if mode == "quantile":
26
+ order = np.argsort(values, kind="mergesort")
27
+ ranks = np.empty(values.size, dtype=float)
28
+ ranks[order] = np.linspace(0.0, 1.0, values.size)
29
+ return ranks
30
+
31
+ lo = np.percentile(values, 5)
32
+ hi = np.percentile(values, 95)
33
+ if hi <= lo:
34
+ lo = np.min(values)
35
+ hi = np.max(values)
36
+
37
+ if hi <= lo:
38
+ norm = np.full(values.size, 0.5, dtype=float)
39
+ else:
40
+ clipped = np.clip(values, lo, hi)
41
+ norm = (clipped - lo) / (hi - lo)
42
+
43
+ if mode == "log":
44
+ # log scaling: spreads out lower values
45
+ norm = np.log1p(norm * 9.0) / np.log(10.0)
46
+ elif mode == "exp":
47
+ # exp scaling: emphasizes higher values
48
+ norm = (np.exp(norm * 2.0) - 1.0) / (np.exp(2.0) - 1.0)
49
+
50
+ return norm
51
+
52
+
53
+ def _compute_scale_multiplier(raw_values, factor, mode="linear"):
54
+ """Compute multiplicative radius factors from raw values.
55
+
56
+ Returns values in [1, factor] after normalization and mode transform.
57
+ """
58
+ factor = float(factor)
59
+ normalized = _normalize_scale_values(raw_values, mode=mode)
60
+ if normalized.size == 0:
61
+ return normalized
62
+ return 1.0 + normalized * (factor - 1.0)
63
+
64
+
65
+ def get_centers_from_surface_parc(vertices, labels, exclude_zero=True, on_surface=True):
66
+ """
67
+ Compute parcel centroids from surface vertices and parcellation labels.
68
+
69
+ Parameters
70
+ ----------
71
+ vertices : ndarray, shape (n_vertices, 3)
72
+ Surface vertex coordinates
73
+ labels : ndarray, shape (n_vertices,)
74
+ Parcellation labels for each vertex
75
+ exclude_zero : bool
76
+ Whether to exclude label 0 (typically "unknown")
77
+ on_surface : bool
78
+ If True, project centroids to the nearest vertex on the surface
79
+ to ensure centers lie exactly on the cortical surface.
80
+ If False, return the mean coordinate (may be inside the brain).
81
+
82
+ Returns
83
+ -------
84
+ centers : dict
85
+ Dictionary mapping label (int) -> centroid coordinates (ndarray, shape (3,))
86
+ """
87
+ unique_labels = np.unique(labels)
88
+ if exclude_zero:
89
+ unique_labels = unique_labels[unique_labels != 0]
90
+
91
+ centers = {}
92
+ for label in unique_labels:
93
+ mask = labels == label
94
+ parcel_vertices = vertices[mask]
95
+ centroid = parcel_vertices.mean(axis=0)
96
+
97
+ if on_surface:
98
+ # Project centroid to nearest vertex within the parcel
99
+ distances = np.linalg.norm(parcel_vertices - centroid, axis=1)
100
+ nearest_idx = np.argmin(distances)
101
+ centers[int(label)] = parcel_vertices[nearest_idx]
102
+ else:
103
+ centers[int(label)] = centroid
104
+
105
+ return centers
106
+
107
+
108
+ def create_network(
109
+ centers,
110
+ connectivity_matrix,
111
+ labels=None,
112
+ threshold_percentile=95,
113
+ directed=False,
114
+ edge_data_key="weight",
115
+ node_types=None,
116
+ ):
117
+ """
118
+ Create a NetworkX graph from parcel centers and connectivity matrix.
119
+
120
+ Parameters
121
+ ----------
122
+ centers : dict
123
+ Dictionary mapping label -> (x, y, z) coordinates
124
+ connectivity_matrix : ndarray or dict[str, ndarray]
125
+ Connectivity matrix. Can be asymmetric for directed graphs.
126
+ For multilayer graphs, pass a dict mapping layer_name -> matrix.
127
+ labels : list, optional
128
+ Ordered list of labels corresponding to matrix rows/cols.
129
+ If None, uses sorted keys from centers.
130
+ threshold_percentile : float
131
+ Only include edges above this percentile (0-100). Set to 0 for all edges.
132
+ directed : bool
133
+ If True, create a MultiDiGraph; otherwise create a Graph.
134
+ edge_data_key : str
135
+ Semantic edge attribute key used to store matrix values (e.g., "gain").
136
+ When different from "weight", a compatibility "weight" attribute is
137
+ also stored with the same value.
138
+ node_types : dict, optional
139
+ Optional mapping label -> node type for heterogeneous graphs.
140
+
141
+ Returns
142
+ -------
143
+ G : networkx.Graph or networkx.MultiGraph or networkx.MultiDiGraph
144
+ Network with node positions and semantic edge attributes.
145
+ """
146
+ if labels is None:
147
+ labels = sorted(centers.keys())
148
+
149
+ is_multilayer = isinstance(connectivity_matrix, dict)
150
+
151
+ # Create graph
152
+ if directed:
153
+ G = nx.MultiDiGraph()
154
+ else:
155
+ G = nx.MultiGraph() if is_multilayer else nx.Graph()
156
+
157
+ def _infer_node_type(label):
158
+ if isinstance(label, str) and ":" in label:
159
+ prefix = label.split(":", 1)[0].strip().lower()
160
+ return prefix if prefix else None
161
+ return None
162
+
163
+ # Add nodes with positions
164
+ for label in labels:
165
+ if label in centers:
166
+ pos = centers[label]
167
+ node_attrs = {"pos": pos, "x": pos[0], "y": pos[1], "z": pos[2]}
168
+ if node_types is not None and label in node_types:
169
+ node_attrs["node_type"] = node_types[label]
170
+ else:
171
+ inferred_type = _infer_node_type(label)
172
+ if inferred_type is not None:
173
+ node_attrs["node_type"] = inferred_type
174
+ G.add_node(label, **node_attrs)
175
+
176
+ if is_multilayer:
177
+ layer_matrices = connectivity_matrix
178
+ else:
179
+ layer_matrices = {"default": connectivity_matrix}
180
+
181
+ n = len(labels)
182
+ for layer_name, layer_matrix in layer_matrices.items():
183
+ matrix = np.asarray(layer_matrix)
184
+ if matrix.shape != (n, n):
185
+ raise ValueError(
186
+ f"Layer '{layer_name}' has shape {matrix.shape}, expected {(n, n)}"
187
+ )
188
+
189
+ # Compute threshold per layer
190
+ if threshold_percentile > 0:
191
+ nonzero = matrix[matrix > 0]
192
+ if len(nonzero) > 0:
193
+ threshold = np.percentile(nonzero, threshold_percentile)
194
+ else:
195
+ threshold = 0
196
+ else:
197
+ threshold = 0
198
+
199
+ # Add edges
200
+ for i in range(n):
201
+ for j in range(n):
202
+ if i == j:
203
+ continue
204
+ if not directed and i > j:
205
+ continue
206
+
207
+ edge_value = matrix[i, j]
208
+ # For undirected graphs, also check the transpose entry
209
+ if not directed:
210
+ edge_value = max(edge_value, matrix[j, i])
211
+ if edge_value > threshold:
212
+ attrs = {edge_data_key: float(edge_value)}
213
+ if edge_data_key != "weight":
214
+ attrs["weight"] = float(edge_value)
215
+ if is_multilayer:
216
+ attrs["layer"] = str(layer_name)
217
+ G.add_edge(labels[i], labels[j], **attrs)
218
+
219
+ return G
220
+
221
+
222
+ def create_sphere_mesh(center, radius, resolution=12):
223
+ """
224
+ Create a UV sphere mesh at a given center.
225
+
226
+ Parameters
227
+ ----------
228
+ center : array-like, shape (3,)
229
+ Center coordinates (x, y, z)
230
+ radius : float
231
+ Sphere radius
232
+ resolution : int
233
+ Number of subdivisions (higher = smoother)
234
+
235
+ Returns
236
+ -------
237
+ vertices : ndarray, shape (n_verts, 3)
238
+ faces : ndarray, shape (n_faces, 3)
239
+ """
240
+ u = np.linspace(0, 2 * np.pi, resolution + 1)
241
+ v = np.linspace(0, np.pi, resolution // 2 + 1)
242
+
243
+ verts = []
244
+ for vi in v:
245
+ for ui in u[:-1]:
246
+ x = center[0] + radius * np.sin(vi) * np.cos(ui)
247
+ y = center[1] + radius * np.sin(vi) * np.sin(ui)
248
+ z = center[2] + radius * np.cos(vi)
249
+ verts.append([x, y, z])
250
+
251
+ vertices = np.array(verts, dtype=np.float32)
252
+
253
+ faces = []
254
+ n_u = resolution
255
+ n_v = resolution // 2 + 1
256
+
257
+ for j in range(n_v - 1):
258
+ for i in range(n_u):
259
+ v0 = j * n_u + i
260
+ v1 = j * n_u + (i + 1) % n_u
261
+ v2 = (j + 1) * n_u + (i + 1) % n_u
262
+ v3 = (j + 1) * n_u + i
263
+ faces.append([v0, v1, v2])
264
+ faces.append([v0, v2, v3])
265
+
266
+ return vertices, np.array(faces, dtype=np.int32)
267
+
268
+
269
+ def create_node_mesh(G, radius=2.0, resolution=12, nodes=None, data_key=None, scale=None):
270
+ """
271
+ Create a combined mesh of spheres for network nodes.
272
+
273
+ Parameters
274
+ ----------
275
+ G : networkx.Graph
276
+ Network with node positions (pos attribute)
277
+ radius : float
278
+ Base sphere radius for each node
279
+ resolution : int
280
+ Sphere mesh resolution
281
+ nodes : list, optional
282
+ Subset of nodes to include. If None, uses all nodes.
283
+ data_key : str, optional
284
+ Node attribute key to extract values for coloring.
285
+ If provided, returns an overlay array for colormap-based rendering.
286
+ scale : dict, optional
287
+ Dictionary mapping node attribute key to scaling factor, with optional "mode".
288
+ Example: {"strength": 2} scales radius from 1x to 2x based on normalized strength.
289
+ The factor controls the range: radius * (1 + normalized_value * (factor - 1))
290
+ Add "mode": "log", "exp", or "quantile" to change scaling behavior.
291
+ - "linear" (default): uses normalized value directly
292
+ - "log": applies log(1 + normalized * 9) / log(10) for smoother spread
293
+ - "exp": applies (exp(normalized * 2) - 1) / (exp(2) - 1) for emphasis on high values
294
+ - "quantile": spreads values uniformly by rank (best for dense graphs)
295
+
296
+ Returns
297
+ -------
298
+ mesh : GiftiImage
299
+ Combined sphere mesh
300
+ overlay : ndarray, optional
301
+ Per-vertex values for colormap (only if data_key is provided)
302
+ """
303
+ if nodes is None:
304
+ nodes = list(G.nodes())
305
+
306
+ # Filter nodes with positions
307
+ valid_nodes = [n for n in nodes if "pos" in G.nodes[n]]
308
+
309
+ if not valid_nodes:
310
+ raise ValueError("No nodes with positions found")
311
+
312
+ # Pre-compute normalized scale values if scaling is requested
313
+ node_scale_values = {}
314
+ if scale is not None:
315
+ scale_mode = scale.get("mode", "linear")
316
+ for key, factor in scale.items():
317
+ if key == "mode":
318
+ continue
319
+
320
+ key_nodes = [n for n in valid_nodes if key in G.nodes[n]]
321
+ if not key_nodes:
322
+ continue
323
+
324
+ raw_values = [G.nodes[n][key] for n in key_nodes]
325
+ multipliers = _compute_scale_multiplier(raw_values, factor, scale_mode)
326
+ for node, mult in zip(key_nodes, multipliers):
327
+ if node not in node_scale_values:
328
+ node_scale_values[node] = 1.0
329
+ node_scale_values[node] *= mult
330
+
331
+ all_vertices = []
332
+ all_faces = []
333
+ all_values = [] if data_key else None
334
+ vertex_offset = 0
335
+
336
+ for node in valid_nodes:
337
+ center = G.nodes[node]["pos"]
338
+
339
+ # Get scaled radius
340
+ node_radius = radius
341
+ if node in node_scale_values:
342
+ node_radius = radius * node_scale_values[node]
343
+
344
+ verts, faces = create_sphere_mesh(center, node_radius, resolution)
345
+ all_vertices.append(verts)
346
+ all_faces.append(faces + vertex_offset)
347
+ vertex_offset += len(verts)
348
+
349
+ # Extract node data value for coloring
350
+ if data_key is not None:
351
+ node_data = G.nodes[node]
352
+ if data_key in node_data:
353
+ value = node_data[data_key]
354
+ else:
355
+ value = 0.0
356
+ # Assign same value to all vertices of this sphere
357
+ all_values.extend([value] * len(verts))
358
+
359
+ if not all_vertices:
360
+ raise ValueError("No nodes with positions found")
361
+
362
+ combined_verts = np.vstack(all_vertices).astype(np.float32)
363
+ combined_faces = np.vstack(all_faces).astype(np.int32)
364
+
365
+ mesh = _create_gifti_mesh(combined_verts, combined_faces)
366
+
367
+ if data_key is not None:
368
+ return mesh, np.array(all_values, dtype=np.float32)
369
+ return mesh
370
+
371
+
372
+ def create_tube_mesh(start, end, radius=0.3, resolution=8):
373
+ """
374
+ Create a tube (cylinder) mesh between two points.
375
+
376
+ Parameters
377
+ ----------
378
+ start, end : array-like, shape (3,)
379
+ Start and end coordinates
380
+ radius : float
381
+ Tube radius
382
+ resolution : int
383
+ Number of segments around the tube
384
+
385
+ Returns
386
+ -------
387
+ vertices : ndarray, shape (n_verts, 3)
388
+ faces : ndarray, shape (n_faces, 3)
389
+ """
390
+ start = np.array(start)
391
+ end = np.array(end)
392
+
393
+ # Direction vector
394
+ direction = end - start
395
+ length = np.linalg.norm(direction)
396
+ if length < 1e-6:
397
+ return np.array([]).reshape(0, 3), np.array([]).reshape(0, 3)
398
+
399
+ direction = direction / length
400
+
401
+ # Find perpendicular vectors
402
+ if abs(direction[0]) < 0.9:
403
+ perp1 = np.cross(direction, [1, 0, 0])
404
+ else:
405
+ perp1 = np.cross(direction, [0, 1, 0])
406
+ perp1 = perp1 / np.linalg.norm(perp1)
407
+ perp2 = np.cross(direction, perp1)
408
+
409
+ # Generate vertices
410
+ angles = np.linspace(0, 2 * np.pi, resolution, endpoint=False)
411
+
412
+ verts = []
413
+ for angle in angles:
414
+ offset = radius * (np.cos(angle) * perp1 + np.sin(angle) * perp2)
415
+ verts.append(start + offset)
416
+ verts.append(end + offset)
417
+
418
+ vertices = np.array(verts, dtype=np.float32)
419
+
420
+ # Generate faces
421
+ faces = []
422
+ for i in range(resolution):
423
+ i0 = 2 * i
424
+ i1 = 2 * i + 1
425
+ i2 = 2 * ((i + 1) % resolution)
426
+ i3 = 2 * ((i + 1) % resolution) + 1
427
+
428
+ faces.append([i0, i2, i1])
429
+ faces.append([i1, i2, i3])
430
+
431
+ return vertices, np.array(faces, dtype=np.int32)
432
+
433
+
434
+ def create_edge_mesh(G, radius=0.3, resolution=8, edges=None, data_key=None, scale=None):
435
+ """
436
+ Create a combined mesh of tubes for network edges.
437
+
438
+ Parameters
439
+ ----------
440
+ G : networkx.Graph
441
+ Network with node positions (pos attribute)
442
+ radius : float
443
+ Base tube radius for each edge
444
+ resolution : int
445
+ Tube mesh resolution
446
+ edges : list, optional
447
+ Subset of edges to include. If None, uses all edges.
448
+ data_key : str, optional
449
+ Edge attribute key to extract values for coloring (e.g., "weight").
450
+ If provided, returns an overlay array for colormap-based rendering.
451
+ scale : dict, optional
452
+ Dictionary mapping edge attribute key to scaling factor, with optional "mode".
453
+ Example: {"weight": 3} scales radius from 1x to 3x based on normalized weight.
454
+ The factor controls the range: radius * (1 + normalized_value * (factor - 1))
455
+ Add "mode": "log", "exp", or "quantile" to change scaling behavior.
456
+ - "linear" (default): uses normalized value directly
457
+ - "log": applies log(1 + normalized * 9) / log(10) for smoother spread
458
+ - "exp": applies (exp(normalized * 2) - 1) / (exp(2) - 1) for emphasis on high values
459
+ - "quantile": spreads values uniformly by rank (best for dense graphs)
460
+
461
+ Returns
462
+ -------
463
+ mesh : GiftiImage
464
+ Combined tube mesh
465
+ overlay : ndarray, optional
466
+ Per-vertex values for colormap (only if data_key is provided)
467
+ """
468
+ if edges is None:
469
+ edges = list(G.edges(keys=True)) if G.is_multigraph() else list(G.edges())
470
+
471
+ def _edge_nodes(edge):
472
+ return edge[0], edge[1]
473
+
474
+ def _edge_lookup_key(edge):
475
+ if G.is_multigraph() and len(edge) >= 3:
476
+ return edge[0], edge[1], edge[2]
477
+ return edge[0], edge[1]
478
+
479
+ def _edge_attrs(edge):
480
+ u, v = _edge_nodes(edge)
481
+ if G.is_multigraph():
482
+ if len(edge) >= 3:
483
+ data = G.get_edge_data(u, v, key=edge[2])
484
+ return data if isinstance(data, dict) else {}
485
+
486
+ data_by_key = G.get_edge_data(u, v)
487
+ if isinstance(data_by_key, dict):
488
+ first = next(iter(data_by_key.values()), {})
489
+ return first if isinstance(first, dict) else {}
490
+ return {}
491
+
492
+ data = G.get_edge_data(u, v)
493
+ return data if isinstance(data, dict) else {}
494
+
495
+ # Filter valid edges (both nodes have positions)
496
+ valid_edges = []
497
+ for edge in edges:
498
+ if len(edge) < 2:
499
+ continue
500
+ u, v = _edge_nodes(edge)
501
+ if u in G.nodes and v in G.nodes and "pos" in G.nodes[u] and "pos" in G.nodes[v]:
502
+ valid_edges.append(edge)
503
+
504
+ if not valid_edges:
505
+ raise ValueError("No edges with valid node positions found")
506
+
507
+ # Pre-compute normalized scale values if scaling is requested
508
+ edge_scale_values = {}
509
+ if scale is not None:
510
+ scale_mode = scale.get("mode", "linear")
511
+ for key, factor in scale.items():
512
+ if key == "mode":
513
+ continue
514
+
515
+ key_edges = []
516
+ raw_values = []
517
+ for edge in valid_edges:
518
+ edge_data = _edge_attrs(edge)
519
+ if edge_data is not None and key in edge_data:
520
+ key_edges.append(edge)
521
+ raw_values.append(edge_data[key])
522
+
523
+ if not raw_values:
524
+ continue
525
+
526
+ multipliers = _compute_scale_multiplier(raw_values, factor, scale_mode)
527
+ for edge, mult in zip(key_edges, multipliers):
528
+ edge_key = _edge_lookup_key(edge)
529
+ if edge_key not in edge_scale_values:
530
+ edge_scale_values[edge_key] = 1.0
531
+ edge_scale_values[edge_key] *= mult
532
+
533
+ all_vertices = []
534
+ all_faces = []
535
+ all_values = [] if data_key else None
536
+ vertex_offset = 0
537
+
538
+ for edge in valid_edges:
539
+ u, v = _edge_nodes(edge)
540
+ start = G.nodes[u]["pos"]
541
+ end = G.nodes[v]["pos"]
542
+
543
+ # Get scaled radius
544
+ edge_radius = radius
545
+ edge_key = _edge_lookup_key(edge)
546
+ if edge_key in edge_scale_values:
547
+ edge_radius = radius * edge_scale_values[edge_key]
548
+
549
+ verts, faces = create_tube_mesh(start, end, edge_radius, resolution)
550
+ if len(verts) == 0:
551
+ continue
552
+
553
+ all_vertices.append(verts)
554
+ all_faces.append(faces + vertex_offset)
555
+ vertex_offset += len(verts)
556
+
557
+ # Extract edge data value for coloring
558
+ if data_key is not None:
559
+ edge_data = _edge_attrs(edge)
560
+ if edge_data is not None and data_key in edge_data:
561
+ value = edge_data[data_key]
562
+ else:
563
+ value = 0.0
564
+ # Assign same value to all vertices of this tube
565
+ all_values.extend([value] * len(verts))
566
+
567
+ if not all_vertices:
568
+ raise ValueError("No edges with valid node positions found")
569
+
570
+ combined_verts = np.vstack(all_vertices).astype(np.float32)
571
+ combined_faces = np.vstack(all_faces).astype(np.int32)
572
+
573
+ mesh = _create_gifti_mesh(combined_verts, combined_faces)
574
+
575
+ if data_key is not None:
576
+ return mesh, np.array(all_values, dtype=np.float32)
577
+ return mesh
578
+
579
+
580
+ def _create_gifti_mesh(vertices, faces):
581
+ """Create a GiftiImage from vertices and faces."""
582
+ coords = nibabel.gifti.GiftiDataArray(
583
+ data=vertices.astype(np.float32),
584
+ intent=nibabel.nifti1.intent_codes["NIFTI_INTENT_POINTSET"],
585
+ datatype=nibabel.nifti1.data_type_codes["NIFTI_TYPE_FLOAT32"],
586
+ )
587
+ triangles = nibabel.gifti.GiftiDataArray(
588
+ data=faces.astype(np.int32),
589
+ intent=nibabel.nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"],
590
+ datatype=nibabel.nifti1.data_type_codes["NIFTI_TYPE_INT32"],
591
+ )
592
+ return nibabel.gifti.GiftiImage(darrays=[coords, triangles])
593
+
594
+
595
+ def _apply_log_to_overlay(overlay, vmin=None, vmax=None):
596
+ """Transform overlay values to log10 space for log-scale colormapping.
597
+
598
+ Returns transformed overlay, vmin, vmax for passing to plot_surf.
599
+ Zeros and negatives are clipped to a small epsilon.
600
+ """
601
+ pos = overlay[overlay > 0]
602
+ if len(pos) > 0:
603
+ eps = pos.min() * 0.1
604
+ else:
605
+ eps = 1e-10
606
+ log_overlay = np.log10(np.clip(overlay, eps, None))
607
+ if vmin is not None:
608
+ log_vmin = np.log10(max(vmin, eps))
609
+ else:
610
+ log_vmin = np.log10(eps) if len(pos) == 0 else np.log10(pos.min())
611
+ if vmax is not None:
612
+ log_vmax = np.log10(max(vmax, eps))
613
+ else:
614
+ log_vmax = log_overlay.max()
615
+ return log_overlay, log_vmin, log_vmax
616
+
617
+
618
+ def plot_network_on_surface(
619
+ G,
620
+ ax=None,
621
+ template="fsLR",
622
+ density="32k",
623
+ hemi=("lh", "rh"),
624
+ view="top",
625
+ surface_alpha=0.3,
626
+ node_radius=2.0,
627
+ node_color="crimson",
628
+ node_cmap=None,
629
+ node_data_key=None,
630
+ node_scale=None,
631
+ node_vmin=None,
632
+ node_vmax=None,
633
+ node_resolution=12,
634
+ edge_radius=0.3,
635
+ edge_color="steelblue",
636
+ edge_cmap=None,
637
+ edge_data_key="weight",
638
+ edge_scale=None,
639
+ edge_vmin=None,
640
+ edge_vmax=None,
641
+ edge_resolution=8,
642
+ show_edges=True,
643
+ show_nodes=True,
644
+ nodes=None,
645
+ edges=None,
646
+ extra_surfaces=None,
647
+ extra_colors=None,
648
+ extra_alphas=None,
649
+ **kwargs,
650
+ ):
651
+ """
652
+ Plot a network graph on brain surface with depth-correct rendering.
653
+
654
+ Parameters
655
+ ----------
656
+ G : networkx.Graph
657
+ Network with node positions (pos attribute)
658
+ ax : matplotlib.axes.Axes, optional
659
+ Axes to plot on. If None, creates new figure.
660
+ template : str
661
+ Surface template ("fsLR", "fsaverage")
662
+ density : str
663
+ Surface density ("32k", "10k", etc.)
664
+ hemi : str or tuple/list
665
+ Hemisphere(s) to plot. Can be "lh", "rh", or ("lh", "rh") for both.
666
+ Default is ("lh", "rh") to show both hemispheres merged.
667
+ view : str
668
+ View angle ("lateral", "medial", "top", "front", etc.)
669
+ surface_alpha : float
670
+ Transparency of brain surface
671
+ node_radius : float
672
+ Radius of node spheres
673
+ node_color : str or "auto"
674
+ Uniform color for nodes. If "auto" and node_data_key is set,
675
+ uses colormap based on node data. If "auto" and node has "color"
676
+ attribute, uses that color.
677
+ node_cmap : str, optional
678
+ Colormap for nodes based on node_data_key attribute.
679
+ If provided, nodes are colored by their data values.
680
+ node_data_key : str, optional
681
+ Node attribute to use for colormap (e.g., "degree", "strength")
682
+ node_scale : dict, optional
683
+ Dictionary mapping node attribute key to max scaling factor for radius.
684
+ Values are normalized to [0,1] first, then scaled.
685
+ Example: {"strength": 3} scales radius from 1x (min) to 3x (max).
686
+ Add "mode": "log", "exp", or "quantile" to change scaling curve.
687
+ - "linear" (default): linear interpolation between 1x and factor
688
+ - "log": compresses high values, spreads low values
689
+ - "exp": emphasizes high values, compresses low values
690
+ - "quantile": uniform spread by rank, robust for dense graphs
691
+ node_vmin, node_vmax : float, optional
692
+ Color scale limits for node colormap
693
+ node_resolution : int
694
+ Resolution of node sphere meshes
695
+ edge_radius : float
696
+ Radius of edge tubes
697
+ edge_color : str or "auto"
698
+ Uniform color for edges. If "auto" and edge_data_key is set,
699
+ uses colormap based on edge data. Falls back to "steelblue"
700
+ when "auto" but no data key is provided.
701
+ edge_cmap : str, optional
702
+ Colormap for edges based on edge_data_key attribute.
703
+ If provided, edges are colored by their data values.
704
+ edge_data_key : str
705
+ Edge attribute to use for colormap (default: "weight")
706
+ edge_scale : dict, optional
707
+ Dictionary mapping edge attribute key to max scaling factor for radius.
708
+ Values are normalized to [0,1] first, then scaled.
709
+ Example: {"weight": 5} scales radius from 1x (min) to 5x (max).
710
+ Add "mode": "log", "exp", or "quantile" to change scaling curve.
711
+ - "linear" (default): linear interpolation between 1x and factor
712
+ - "log": compresses high values, spreads low values
713
+ - "exp": emphasizes high values, compresses low values
714
+ - "quantile": uniform spread by rank, robust for dense graphs
715
+ edge_vmin, edge_vmax : float, optional
716
+ Color scale limits for edge colormap
717
+ edge_resolution : int
718
+ Resolution of edge tube meshes
719
+ show_edges : bool
720
+ Whether to show edges
721
+ show_nodes : bool
722
+ Whether to show nodes
723
+ nodes : list, optional
724
+ Subset of nodes to show
725
+ edges : list, optional
726
+ Subset of edges to show
727
+ extra_surfaces : list, optional
728
+ Additional surface meshes (GiftiImage) to render alongside the brain
729
+ and network. These are depth-sorted together with all other elements.
730
+ Example: head/skull surface from MNE BEM.
731
+ extra_colors : list, optional
732
+ Colors for extra_surfaces (one per surface).
733
+ extra_alphas : list, optional
734
+ Alpha values for extra_surfaces (one per surface).
735
+ **kwargs
736
+ Additional arguments passed to plot_surf
737
+
738
+ Returns
739
+ -------
740
+ fig : matplotlib.figure.Figure
741
+ ax : matplotlib.axes.Axes
742
+ mappables : dict
743
+ Dictionary containing ScalarMappable objects for colorbars.
744
+ Keys are 'nodes' and/or 'edges' (only present if colormap was used).
745
+ Use with fig.colorbar(mappables['nodes'], ax=ax, label='Node strength')
746
+ """
747
+ if ax is None:
748
+ fig, ax = plt.subplots(figsize=(12, 10))
749
+ return_fig = True
750
+ else:
751
+ fig = ax.figure
752
+ return_fig = False
753
+
754
+ # Build list of additional surfaces (start with user-provided extras)
755
+ additional_surfaces = list(extra_surfaces) if extra_surfaces else []
756
+ additional_colors = list(extra_colors) if extra_colors else []
757
+ additional_alphas = list(extra_alphas) if extra_alphas else []
758
+ n_extras = len(additional_surfaces)
759
+ # Pad colors/alphas lists to match extras count
760
+ while len(additional_colors) < n_extras:
761
+ additional_colors.append('wheat')
762
+ while len(additional_alphas) < n_extras:
763
+ additional_alphas.append(0.1)
764
+ additional_overlays = [None] * n_extras
765
+ additional_cmaps = [None] * n_extras
766
+ additional_vmins = [None] * n_extras
767
+ additional_vmaxs = [None] * n_extras
768
+
769
+ # Handle "auto" node_color
770
+ use_node_cmap = False
771
+ if node_color == "auto":
772
+ if node_data_key is not None:
773
+ # Use colormap based on node data
774
+ use_node_cmap = True
775
+ if node_cmap is None:
776
+ node_cmap = "viridis" # default colormap
777
+ # Note: per-node colors from "color" attribute would require
778
+ # separate meshes per node, which we don't support yet
779
+ elif node_cmap is not None:
780
+ use_node_cmap = True
781
+
782
+ # Detect log mode from scale dicts
783
+ node_scale_mode = (node_scale or {}).get("mode", "linear")
784
+ edge_scale_mode = (edge_scale or {}).get("mode", "linear")
785
+
786
+ # Create node mesh
787
+ if show_nodes:
788
+ if nodes is None:
789
+ nodes = [n for n in G.nodes() if G.degree(n) > 0] # nodes with edges
790
+ if nodes:
791
+ if use_node_cmap and node_data_key is not None:
792
+ # Use colormap based on node data
793
+ node_mesh, node_overlay = create_node_mesh(
794
+ G,
795
+ radius=node_radius,
796
+ resolution=node_resolution,
797
+ nodes=nodes,
798
+ data_key=node_data_key,
799
+ scale=node_scale,
800
+ )
801
+ # Apply log transform to overlay if scale mode is "log"
802
+ _node_ovl_vmin, _node_ovl_vmax = node_vmin, node_vmax
803
+ if node_scale_mode == "log":
804
+ node_overlay, _node_ovl_vmin, _node_ovl_vmax = (
805
+ _apply_log_to_overlay(node_overlay, node_vmin, node_vmax)
806
+ )
807
+ additional_surfaces.append(node_mesh)
808
+ additional_colors.append(None)
809
+ additional_overlays.append(node_overlay)
810
+ additional_cmaps.append(node_cmap)
811
+ additional_vmins.append(_node_ovl_vmin)
812
+ additional_vmaxs.append(_node_ovl_vmax)
813
+ additional_alphas.append(1.0)
814
+ elif node_color == "auto":
815
+ # Group nodes by their 'color' attribute for per-node colors
816
+ color_groups = {}
817
+ for n in nodes:
818
+ c = G.nodes[n].get('color', 'crimson')
819
+ color_groups.setdefault(c, []).append(n)
820
+ for c, group_nodes in color_groups.items():
821
+ mesh = create_node_mesh(
822
+ G, radius=node_radius, resolution=node_resolution,
823
+ nodes=group_nodes, scale=node_scale
824
+ )
825
+ additional_surfaces.append(mesh)
826
+ additional_colors.append(c)
827
+ additional_overlays.append(None)
828
+ additional_cmaps.append(None)
829
+ additional_vmins.append(None)
830
+ additional_vmaxs.append(None)
831
+ additional_alphas.append(1.0)
832
+ else:
833
+ # Use uniform color
834
+ node_mesh = create_node_mesh(
835
+ G, radius=node_radius, resolution=node_resolution, nodes=nodes, scale=node_scale
836
+ )
837
+ additional_surfaces.append(node_mesh)
838
+ additional_colors.append(node_color)
839
+ additional_overlays.append(None)
840
+ additional_cmaps.append(None)
841
+ additional_vmins.append(None)
842
+ additional_vmaxs.append(None)
843
+ additional_alphas.append(1.0)
844
+
845
+ # Handle "auto" edge_color (mirrors node_color logic)
846
+ use_edge_cmap = False
847
+ if edge_color == "auto":
848
+ if edge_data_key is not None:
849
+ use_edge_cmap = True
850
+ if edge_cmap is None:
851
+ edge_cmap = "viridis"
852
+ elif edge_cmap is not None:
853
+ use_edge_cmap = True
854
+
855
+ # Create edge mesh
856
+ if show_edges:
857
+ if edges is None:
858
+ edges = list(G.edges(keys=True)) if G.is_multigraph() else list(G.edges())
859
+ if edges:
860
+ if use_edge_cmap and edge_data_key is not None:
861
+ # Use colormap based on edge data
862
+ edge_mesh, edge_overlay = create_edge_mesh(
863
+ G,
864
+ radius=edge_radius,
865
+ resolution=edge_resolution,
866
+ edges=edges,
867
+ data_key=edge_data_key,
868
+ scale=edge_scale,
869
+ )
870
+ # Apply log transform to overlay if scale mode is "log"
871
+ _edge_ovl_vmin, _edge_ovl_vmax = edge_vmin, edge_vmax
872
+ if edge_scale_mode == "log":
873
+ edge_overlay, _edge_ovl_vmin, _edge_ovl_vmax = (
874
+ _apply_log_to_overlay(edge_overlay, edge_vmin, edge_vmax)
875
+ )
876
+ additional_surfaces.append(edge_mesh)
877
+ additional_colors.append(None)
878
+ additional_overlays.append(edge_overlay)
879
+ additional_cmaps.append(edge_cmap)
880
+ additional_vmins.append(_edge_ovl_vmin)
881
+ additional_vmaxs.append(_edge_ovl_vmax)
882
+ else:
883
+ # Use uniform color
884
+ edge_mesh = create_edge_mesh(
885
+ G, radius=edge_radius, resolution=edge_resolution, edges=edges, scale=edge_scale
886
+ )
887
+ additional_surfaces.append(edge_mesh)
888
+ additional_colors.append(
889
+ edge_color if edge_color != "auto" else "steelblue"
890
+ )
891
+ additional_overlays.append(None)
892
+ additional_cmaps.append(None)
893
+ additional_vmins.append(None)
894
+ additional_vmaxs.append(None)
895
+ additional_alphas.append(1.0)
896
+
897
+ # Track overlay data for creating ScalarMappables
898
+ node_overlay_data = None
899
+ edge_overlay_data = None
900
+
901
+ if use_node_cmap and node_data_key is not None and show_nodes and nodes:
902
+ node_overlay_data = node_overlay
903
+
904
+ if use_edge_cmap and edge_data_key is not None and show_edges and edges:
905
+ edge_overlay_data = edge_overlay
906
+
907
+ # Plot with merged hemispheres (single call for proper depth sorting)
908
+ bsplot.surface.plot_surf(
909
+ template,
910
+ hemi=hemi,
911
+ view=view,
912
+ ax=ax,
913
+ surface_density=density,
914
+ alpha=surface_alpha,
915
+ additional_surfaces=additional_surfaces if additional_surfaces else None,
916
+ additional_colors=additional_colors if additional_colors else None,
917
+ additional_alphas=additional_alphas if additional_alphas else None,
918
+ additional_overlays=additional_overlays if additional_overlays else None,
919
+ additional_cmaps=additional_cmaps if additional_cmaps else None,
920
+ additional_vmins=additional_vmins if additional_vmins else None,
921
+ additional_vmaxs=additional_vmaxs if additional_vmaxs else None,
922
+ **kwargs,
923
+ )
924
+
925
+ ax.set_aspect("equal")
926
+
927
+ # Create ScalarMappables for colorbars
928
+ mappables = {}
929
+
930
+ if node_overlay_data is not None:
931
+ import matplotlib.cm as cm
932
+ import matplotlib.colors as mcolors
933
+
934
+ # Use raw (untransformed) overlay data for ScalarMappable range
935
+ raw_node_overlay = node_overlay_data
936
+ if node_scale_mode == "log":
937
+ # Recover raw values: overlay_data here is already log-transformed;
938
+ # rebuild raw for the ScalarMappable from the mesh call
939
+ raw_node_overlay = 10.0 ** node_overlay_data
940
+ vmin = node_vmin if node_vmin is not None else np.min(raw_node_overlay)
941
+ vmax = node_vmax if node_vmax is not None else np.max(raw_node_overlay)
942
+ if node_scale_mode == "log":
943
+ eps = max(vmin, raw_node_overlay[raw_node_overlay > 0].min() * 0.1) if np.any(raw_node_overlay > 0) else 1e-10
944
+ norm = mcolors.LogNorm(vmin=max(vmin, eps), vmax=vmax)
945
+ else:
946
+ norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
947
+ node_sm = cm.ScalarMappable(cmap=_resolve_cmap(node_cmap), norm=norm)
948
+ node_sm.set_array(raw_node_overlay)
949
+ mappables['nodes'] = node_sm
950
+
951
+ if edge_overlay_data is not None:
952
+ import matplotlib.cm as cm
953
+ import matplotlib.colors as mcolors
954
+
955
+ # Use raw (untransformed) overlay data for ScalarMappable range
956
+ raw_edge_overlay = edge_overlay_data
957
+ if edge_scale_mode == "log":
958
+ raw_edge_overlay = 10.0 ** edge_overlay_data
959
+ vmin = edge_vmin if edge_vmin is not None else np.min(raw_edge_overlay)
960
+ vmax = edge_vmax if edge_vmax is not None else np.max(raw_edge_overlay)
961
+ if edge_scale_mode == "log":
962
+ eps = max(vmin, raw_edge_overlay[raw_edge_overlay > 0].min() * 0.1) if np.any(raw_edge_overlay > 0) else 1e-10
963
+ norm = mcolors.LogNorm(vmin=max(vmin, eps), vmax=vmax)
964
+ else:
965
+ norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
966
+ edge_sm = cm.ScalarMappable(cmap=_resolve_cmap(edge_cmap), norm=norm)
967
+ edge_sm.set_array(raw_edge_overlay)
968
+ mappables['edges'] = edge_sm
969
+
970
+ if return_fig:
971
+ ax.axis("off")
972
+ return fig, ax, mappables
973
+
974
+ return fig, ax, mappables