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.
- bsplot/__init__.py +23 -0
- bsplot/anat.py +55 -0
- bsplot/animate.py +194 -0
- bsplot/bioicons.py +777 -0
- bsplot/brain.py +188 -0
- bsplot/colors.py +1935 -0
- bsplot/data/HCP_avg-SC.txt +379 -0
- bsplot/data/MNI152.rh.pial +0 -0
- bsplot/data/STN_lh.nii.gz +0 -0
- bsplot/data/STN_rh.nii.gz +0 -0
- bsplot/data/__init__.py +22 -0
- bsplot/data/left_electrode.ply +257270 -0
- bsplot/data/parcellations/HCP-MMP1.L.label.gii +442 -0
- bsplot/data/parcellations/HCP-MMP1.R.label.gii +442 -0
- bsplot/data/parcellations/code/split_hcpmmp1.sh +4 -0
- bsplot/data/right_electrode.ply +257270 -0
- bsplot/data/surface.py +112 -0
- bsplot/data/tpl-MNI152NLin2009b_atlas-hcpmmp1_desc-ordered_dseg.nii.gz +0 -0
- bsplot/figure.py +177 -0
- bsplot/graph/__init__.py +31 -0
- bsplot/graph/edges.py +312 -0
- bsplot/graph/flowchart.py +762 -0
- bsplot/graph/layout.py +201 -0
- bsplot/graph/network.py +974 -0
- bsplot/graph/nodes.py +452 -0
- bsplot/panels.py +110 -0
- bsplot/scientific_color_maps/__init__.py +0 -0
- bsplot/scientific_color_maps/_sync_from_zenodo_download.sh +1 -0
- bsplot/scientific_color_maps/batlowK.txt +256 -0
- bsplot/scientific_color_maps/batlowW.txt +256 -0
- bsplot/scientific_color_maps/cyclic/bamO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/brocO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/corkO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/romaO.txt +256 -0
- bsplot/scientific_color_maps/cyclic/vikO.txt +256 -0
- bsplot/scientific_color_maps/diverging/bam.txt +256 -0
- bsplot/scientific_color_maps/diverging/berlin.txt +256 -0
- bsplot/scientific_color_maps/diverging/broc.txt +256 -0
- bsplot/scientific_color_maps/diverging/cork.txt +256 -0
- bsplot/scientific_color_maps/diverging/lisbon.txt +256 -0
- bsplot/scientific_color_maps/diverging/managua.txt +256 -0
- bsplot/scientific_color_maps/diverging/roma.txt +256 -0
- bsplot/scientific_color_maps/diverging/tofino.txt +256 -0
- bsplot/scientific_color_maps/diverging/vanimo.txt +256 -0
- bsplot/scientific_color_maps/diverging/vik.txt +256 -0
- bsplot/scientific_color_maps/multisequential/bukavu.txt +256 -0
- bsplot/scientific_color_maps/multisequential/fes.txt +256 -0
- bsplot/scientific_color_maps/multisequential/oleron.txt +256 -0
- bsplot/scientific_color_maps/naviaW.txt +256 -0
- bsplot/scientific_color_maps/sequential/acton.txt +256 -0
- bsplot/scientific_color_maps/sequential/bamako.txt +256 -0
- bsplot/scientific_color_maps/sequential/batlow.txt +256 -0
- bsplot/scientific_color_maps/sequential/bilbao.txt +256 -0
- bsplot/scientific_color_maps/sequential/buda.txt +256 -0
- bsplot/scientific_color_maps/sequential/davos.txt +256 -0
- bsplot/scientific_color_maps/sequential/devon.txt +256 -0
- bsplot/scientific_color_maps/sequential/glasgow.txt +256 -0
- bsplot/scientific_color_maps/sequential/grayC.txt +256 -0
- bsplot/scientific_color_maps/sequential/hawaii.txt +256 -0
- bsplot/scientific_color_maps/sequential/imola.txt +256 -0
- bsplot/scientific_color_maps/sequential/lajolla.txt +256 -0
- bsplot/scientific_color_maps/sequential/lapaz.txt +256 -0
- bsplot/scientific_color_maps/sequential/lipari.txt +256 -0
- bsplot/scientific_color_maps/sequential/navia.txt +256 -0
- bsplot/scientific_color_maps/sequential/nuuk.txt +256 -0
- bsplot/scientific_color_maps/sequential/oslo.txt +256 -0
- bsplot/scientific_color_maps/sequential/tokyo.txt +256 -0
- bsplot/scientific_color_maps/sequential/turku.txt +256 -0
- bsplot/streamlines.py +314 -0
- bsplot/style.py +791 -0
- bsplot/styles/black.mplstyle +78 -0
- bsplot/styles/bss.mplstyle +50 -0
- bsplot/styles/bwcomp.mplstyle +30 -0
- bsplot/styles/nature.mplstyle +57 -0
- bsplot/styles/transparent.mplstyle +16 -0
- bsplot/styles/tvbo.mplstyle +57 -0
- bsplot/surface.py +2370 -0
- bsplot/templates.py +270 -0
- bsplot/text.py +199 -0
- bsplot/text2obj.py +59 -0
- bsplot/timeseries.py +10 -0
- bsplot/utils.py +13 -0
- bsplot/volume.py +958 -0
- bsplot-0.0.2.dist-info/METADATA +60 -0
- bsplot-0.0.2.dist-info/RECORD +89 -0
- bsplot-0.0.2.dist-info/WHEEL +5 -0
- bsplot-0.0.2.dist-info/entry_points.txt +2 -0
- bsplot-0.0.2.dist-info/licenses/LICENSE +193 -0
- bsplot-0.0.2.dist-info/top_level.txt +1 -0
bsplot/graph/network.py
ADDED
|
@@ -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
|