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
bsplot/surface.py ADDED
@@ -0,0 +1,2370 @@
1
+ import os
2
+ import matplotlib.pyplot as plt
3
+ import nibabel
4
+ import numpy as np
5
+ from matplotlib.axes import Axes as MplAxes # NEW
6
+ from matplotlib.collections import PolyCollection
7
+ from bsplot.colors import Colormap
8
+
9
+ def vol_to_mesh(
10
+ img,
11
+ level=None,
12
+ smooth=True,
13
+ smooth_sigma=1.5,
14
+ mesh_smooth_iterations=20,
15
+ step_size=1,
16
+ world_coords=True,
17
+ return_values=False,
18
+ value_threshold=None,
19
+ threshold=None,
20
+ labels=None,
21
+ label_names=None,
22
+ ):
23
+ """
24
+ Extract a surface mesh from a 3D volume using marching cubes.
25
+
26
+ This is useful for visualizing subcortical structures (e.g., STN, GPi)
27
+ or any volumetric data as a 3D surface.
28
+
29
+ Parameters
30
+ ----------
31
+ img : str, pathlib.Path, or nibabel.Nifti1Image
32
+ Path to a NIfTI file or a loaded nibabel image.
33
+ level : float or None
34
+ The iso-value at which to extract the surface. If None and no
35
+ ``threshold`` is given, defaults to 10% of the volume maximum.
36
+ smooth : bool, default True
37
+ If True, apply Gaussian smoothing to the volume before extraction.
38
+ smooth_sigma : float, default 1.5
39
+ Standard deviation for Gaussian smoothing of the volume.
40
+ mesh_smooth_iterations : int, default 20
41
+ Number of Laplacian smoothing iterations to apply to the mesh.
42
+ Set to 0 to disable mesh smoothing.
43
+ step_size : int, default 1
44
+ Step size for marching cubes. Larger values give coarser meshes.
45
+ world_coords : bool, default True
46
+ If True, transform vertices to world (scanner) coordinates using
47
+ the image affine. If False, return voxel coordinates.
48
+ return_values : bool, default False
49
+ If True, also return the original volume intensity values sampled
50
+ at each vertex position (using trilinear interpolation). These can
51
+ be used directly as an overlay in plot_surf.
52
+ value_threshold : float or None, default None
53
+ If provided (and return_values=True), values below this threshold
54
+ are set to NaN. This allows only high-intensity regions to be
55
+ colored when used as an overlay.
56
+ threshold : float or None, default None
57
+ If provided, used as the iso-level for marching cubes on the
58
+ (optionally smoothed) continuous data. The extracted mesh traces
59
+ the exact contour where volume intensity equals this value.
60
+ Overrides ``level`` when both are given.
61
+ labels : list of int, or None, default None
62
+ If provided, treat the volume as a label/segmentation image
63
+ (e.g. FreeSurfer aseg.mgz). A separate mesh is extracted for
64
+ each label ID. Returns a dict of ``{name: GiftiImage}`` instead
65
+ of a single GiftiImage.
66
+ label_names : dict or None, default None
67
+ Mapping from label ID (int) to human-readable name (str).
68
+ If None and the volume looks like a FreeSurfer segmentation,
69
+ names are looked up from the FreeSurfer color LUT automatically.
70
+ If a name cannot be resolved the integer ID is used as key.
71
+
72
+ Returns
73
+ -------
74
+ surface : nibabel.gifti.GiftiImage or dict
75
+ A single GiftiImage when ``labels`` is None.
76
+ A ``dict[str | int, GiftiImage]`` when ``labels`` is provided.
77
+ A GiftiImage that can be passed directly to plot_surf(surface=...).
78
+ values : ndarray, shape (n_vertices,), optional
79
+ Only returned if return_values=True. Original volume values
80
+ interpolated at each vertex position (can be used as overlay to
81
+ color the surface by the underlying volume intensity).
82
+
83
+ Examples
84
+ --------
85
+ >>> from bsplot.surface import vol_to_mesh, plot_surf
86
+ >>> # Simple usage - returns GiftiImage
87
+ >>> surface = vol_to_mesh('subcortical_structure.nii.gz')
88
+ >>> fig = plot_surf(surface)
89
+ >>>
90
+ >>> # With values for overlay
91
+ >>> surface, values = vol_to_mesh('subcortical_structure.nii.gz', return_values=True)
92
+ >>> fig = plot_surf(surface, overlay=values)
93
+ >>>
94
+ >>> # Only mesh voxels above a threshold
95
+ >>> surface = vol_to_mesh('efield.nii.gz', threshold=100)
96
+ >>>
97
+ >>> # Extract multiple labels from a segmentation volume
98
+ >>> meshes = vol_to_mesh('aseg.mgz', labels=[10, 11, 12, 13, 17, 18])
99
+ >>> # meshes is a dict: {"Left-Thalamus": GiftiImage, ...}
100
+ >>> plot_surf(list(meshes.values()), view="anterior")
101
+ """
102
+ from skimage import measure
103
+ from scipy import ndimage
104
+
105
+ # Load image if path provided
106
+ if isinstance(img, (str, os.PathLike)):
107
+ img = nibabel.load(os.fspath(img))
108
+
109
+ data = img.get_fdata()
110
+ affine = img.affine
111
+
112
+ # --- Multi-label path ---
113
+ if labels is not None:
114
+ import warnings
115
+
116
+ # Resolve label names and colors from FreeSurfer LUT
117
+ names, lut_colors = _resolve_label_names(labels, label_names)
118
+
119
+ result = LabeledMeshes()
120
+ result.colors = {}
121
+ for lab_id in labels:
122
+ binary = (np.round(data) == lab_id).astype(np.float32)
123
+ if binary.sum() == 0:
124
+ continue # skip empty labels
125
+ name = names.get(lab_id, lab_id)
126
+
127
+ # Iteratively reduce smooth_sigma for small structures until
128
+ # marching cubes succeeds (Gaussian blur can push the max
129
+ # below the 0.5 iso-level for tiny masks).
130
+ _sigma = smooth_sigma
131
+ mesh = None
132
+ while _sigma >= 0:
133
+ try:
134
+ mesh = vol_to_mesh(
135
+ nibabel.Nifti1Image(binary, affine),
136
+ level=0.5,
137
+ smooth=smooth and _sigma > 0,
138
+ smooth_sigma=max(_sigma, 0.1),
139
+ mesh_smooth_iterations=mesh_smooth_iterations,
140
+ step_size=step_size,
141
+ world_coords=world_coords,
142
+ return_values=False,
143
+ )
144
+ if _sigma < smooth_sigma:
145
+ n_vox = int(binary.sum())
146
+ warnings.warn(
147
+ f"Label {lab_id} ({name}): reduced smooth_sigma "
148
+ f"from {smooth_sigma} to {_sigma} ({n_vox} voxels).",
149
+ stacklevel=2,
150
+ )
151
+ break
152
+ except ValueError:
153
+ _sigma -= 0.5
154
+ if _sigma < 0:
155
+ n_vox = int(binary.sum())
156
+ warnings.warn(
157
+ f"Skipping label {lab_id} ({name}): marching cubes "
158
+ f"failed even without smoothing "
159
+ f"({n_vox} voxels).",
160
+ stacklevel=2,
161
+ )
162
+ if mesh is None:
163
+ continue
164
+ result[name] = mesh
165
+ if lab_id in lut_colors:
166
+ result.colors[name] = lut_colors[lab_id]
167
+ return result
168
+
169
+ # --- Single-volume path (original behavior) ---
170
+
171
+ # Optional smoothing for nicer surfaces
172
+ if smooth:
173
+ data = ndimage.gaussian_filter(data, sigma=smooth_sigma)
174
+
175
+ # Determine iso-level: explicit threshold overrides the default.
176
+ if threshold is not None:
177
+ level = threshold
178
+ elif level is None:
179
+ level = data.max() * 0.1
180
+
181
+ # Extract isosurface using marching cubes
182
+ verts, faces, normals, _ = measure.marching_cubes(
183
+ data, level=level, step_size=step_size
184
+ )
185
+
186
+ # Sample original volume values at vertex positions (before any transforms)
187
+ if return_values:
188
+ from scipy.ndimage import map_coordinates
189
+
190
+ # Load original (unsmoothed) data for value sampling
191
+ if isinstance(img, (str, os.PathLike)):
192
+ orig_data = nibabel.load(os.fspath(img)).get_fdata()
193
+ else:
194
+ orig_data = img.get_fdata()
195
+ # Interpolate values at vertex voxel coordinates
196
+ vertex_values = map_coordinates(
197
+ orig_data, verts.T, order=1, mode="constant", cval=0
198
+ )
199
+ # Apply threshold: set values below threshold to NaN
200
+ if value_threshold is not None:
201
+ vertex_values = vertex_values.astype(float)
202
+ vertex_values[vertex_values <= value_threshold] = np.nan
203
+
204
+ # Transform to world coordinates if requested
205
+ if world_coords:
206
+ verts = nibabel.affines.apply_affine(affine, verts)
207
+
208
+ # Apply Laplacian smoothing to the mesh
209
+ if mesh_smooth_iterations > 0:
210
+ verts = _laplacian_smooth(verts, faces, iterations=mesh_smooth_iterations)
211
+
212
+ # Create GiftiImage
213
+ surface = _create_gifti_surface(verts, faces)
214
+
215
+ if return_values:
216
+ return surface, vertex_values
217
+ return surface
218
+
219
+
220
+ def _create_gifti_surface(vertices, faces):
221
+ """Create a GiftiImage from vertices and faces arrays.
222
+
223
+ Parameters
224
+ ----------
225
+ vertices : ndarray, shape (n_vertices, 3)
226
+ Vertex coordinates.
227
+ faces : ndarray, shape (n_faces, 3)
228
+ Triangle face indices.
229
+
230
+ Returns
231
+ -------
232
+ gifti : nibabel.gifti.GiftiImage
233
+ A GiftiImage containing the surface mesh.
234
+ """
235
+ # Create coordinate array (NIFTI_INTENT_POINTSET)
236
+ coords = nibabel.gifti.GiftiDataArray(
237
+ data=vertices.astype(np.float32),
238
+ intent=nibabel.nifti1.intent_codes["NIFTI_INTENT_POINTSET"],
239
+ datatype=nibabel.nifti1.data_type_codes["NIFTI_TYPE_FLOAT32"],
240
+ )
241
+
242
+ # Create triangle array (NIFTI_INTENT_TRIANGLE)
243
+ triangles = nibabel.gifti.GiftiDataArray(
244
+ data=faces.astype(np.int32),
245
+ intent=nibabel.nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"],
246
+ datatype=nibabel.nifti1.data_type_codes["NIFTI_TYPE_INT32"],
247
+ )
248
+
249
+ # Create GiftiImage with both arrays
250
+ gifti = nibabel.gifti.GiftiImage(darrays=[coords, triangles])
251
+ return gifti
252
+
253
+
254
+ class LabeledMeshes(dict):
255
+ """A dict subclass returned by ``vol_to_mesh(labels=...)``.
256
+
257
+ Behaves exactly like a regular ``dict[str, GiftiImage]`` but
258
+ additionally carries a ``.colors`` dict mapping the same keys
259
+ to RGBA tuples (0-1 range) parsed from the FreeSurfer color LUT.
260
+ ``plot_surf`` picks these up automatically when no explicit
261
+ ``color`` is provided.
262
+ """
263
+
264
+ colors: dict # name → (r, g, b, a) in [0, 1]
265
+
266
+ def __init__(self, *args, **kwargs):
267
+ super().__init__(*args, **kwargs)
268
+ self.colors = {}
269
+
270
+
271
+ def _resolve_label_names(labels, label_names=None):
272
+ """Return ``(names, colors)`` dicts keyed by label ID.
273
+
274
+ Parameters
275
+ ----------
276
+ labels : list[int]
277
+ Integer label IDs to look up.
278
+ label_names : list[str], dict, or None
279
+ If a list of strings matching *labels* in order, converted into
280
+ a dict. If a dict, used as-is. If ``None``, the FreeSurfer
281
+ color LUT is queried.
282
+
283
+ Returns
284
+ -------
285
+ names : dict[int, str]
286
+ Mapping label-ID → human-readable name.
287
+ colors : dict[int, tuple]
288
+ Mapping label-ID → ``(r, g, b, a)`` with values in [0, 1].
289
+ Empty when the LUT cannot be found.
290
+ """
291
+ # Allow list-of-names shorthand
292
+ if isinstance(label_names, (list, tuple)):
293
+ if len(label_names) != len(labels):
294
+ raise ValueError(
295
+ f"label_names list ({len(label_names)}) must match "
296
+ f"labels list ({len(labels)})"
297
+ )
298
+ label_names = dict(zip(labels, label_names))
299
+
300
+ if isinstance(label_names, dict):
301
+ return label_names, {} # no LUT colors when names are explicit
302
+
303
+ # Try FreeSurfer LUT
304
+ fs_home = os.environ.get("FREESURFER_HOME", "")
305
+ lut_path = os.path.join(fs_home, "FreeSurferColorLUT.txt") if fs_home else ""
306
+ lut_names: dict[int, str] = {}
307
+ lut_colors: dict[int, tuple] = {}
308
+ if os.path.isfile(lut_path):
309
+ with open(lut_path) as f:
310
+ for line in f:
311
+ line = line.strip()
312
+ if not line or line.startswith("#"):
313
+ continue
314
+ parts = line.split()
315
+ if len(parts) >= 6:
316
+ try:
317
+ idx = int(parts[0])
318
+ lut_names[idx] = parts[1]
319
+ r, g, b, a = (
320
+ int(parts[2]),
321
+ int(parts[3]),
322
+ int(parts[4]),
323
+ int(parts[5]),
324
+ )
325
+ lut_colors[idx] = (r / 255, g / 255, b / 255, 1.0)
326
+ except (ValueError, IndexError):
327
+ continue
328
+
329
+ names = {lab: lut_names.get(lab, lab) for lab in labels}
330
+ return names, lut_colors
331
+
332
+
333
+ def _laplacian_smooth(vertices, faces, iterations=10, lambda_factor=0.5):
334
+ """Apply Laplacian smoothing to a mesh.
335
+
336
+ Parameters
337
+ ----------
338
+ vertices : ndarray, shape (n_vertices, 3)
339
+ Vertex coordinates.
340
+ faces : ndarray, shape (n_faces, 3)
341
+ Triangle face indices.
342
+ iterations : int, default 10
343
+ Number of smoothing iterations.
344
+ lambda_factor : float, default 0.5
345
+ Smoothing factor (0-1). Higher values = more smoothing per iteration.
346
+
347
+ Returns
348
+ -------
349
+ vertices_smoothed : ndarray, shape (n_vertices, 3)
350
+ Smoothed vertex coordinates.
351
+ """
352
+ from collections import defaultdict
353
+
354
+ # Build adjacency list
355
+ n_verts = len(vertices)
356
+ neighbors = defaultdict(set)
357
+ for f in faces:
358
+ neighbors[f[0]].update([f[1], f[2]])
359
+ neighbors[f[1]].update([f[0], f[2]])
360
+ neighbors[f[2]].update([f[0], f[1]])
361
+
362
+ verts_new = vertices.copy().astype(np.float64)
363
+ for _ in range(iterations):
364
+ verts_smooth = np.zeros_like(verts_new)
365
+ for i in range(n_verts):
366
+ if neighbors[i]:
367
+ neighbor_verts = verts_new[list(neighbors[i])]
368
+ centroid = neighbor_verts.mean(axis=0)
369
+ verts_smooth[i] = verts_new[i] + lambda_factor * (
370
+ centroid - verts_new[i]
371
+ )
372
+ else:
373
+ verts_smooth[i] = verts_new[i]
374
+ verts_new = verts_smooth
375
+ return verts_new
376
+
377
+
378
+ # Maps view names to the world axis (0=x, 1=y, 2=z) the camera looks along.
379
+ _PROJECTION_AXES = {
380
+ "lateral": 0, "medial": 0, "sagittal": 0,
381
+ "front": 1, "frontal": 1, "anterior": 1, "coronal": 1,
382
+ "back": 1, "posterior": 1,
383
+ "top": 2, "dorsal": 2, "superior": 2, "axial": 2, "horizontal": 2,
384
+ "bottom": 2, "ventral": 2, "inferior": 2,
385
+ }
386
+
387
+
388
+ def _sample_volume_at_vertices(
389
+ vol, world_vertices, threshold=None,
390
+ intensity_projection=None, projection_axis=None,
391
+ ):
392
+ """Sample a NIfTI volume at world-coordinate vertex positions.
393
+
394
+ Transforms vertex positions from world (RAS) space to voxel space
395
+ via the inverse of the volume's affine, then interpolates values
396
+ using ``scipy.ndimage.map_coordinates`` (trilinear, order=1).
397
+
398
+ When *intensity_projection* is set, sampling switches from point
399
+ evaluation to a sweep along the *projection_axis*: for each vertex
400
+ the volume is sampled at every voxel slice along that axis (keeping
401
+ the other two coordinates fixed) and the resulting column is reduced
402
+ with the chosen aggregation (``"max"``, ``"mean"``, etc.). This
403
+ mirrors the behaviour of ``plot_slice(intensity_projection=...)``
404
+ but maps the result onto mesh vertices.
405
+
406
+ Parameters
407
+ ----------
408
+ vol : str, pathlib.Path, or nibabel.Nifti1Image
409
+ Path to a NIfTI file or a loaded nibabel image.
410
+ world_vertices : ndarray, shape (n_vertices, 3)
411
+ Vertex positions in world (RAS) coordinates, matching the
412
+ coordinate space of the volume.
413
+ threshold : float or None
414
+ If provided, sampled values at or below this threshold are set
415
+ to NaN. This excludes low-intensity regions from the colormap
416
+ range and causes them to render as grey in ``plot_surf``.
417
+ intensity_projection : str or None
418
+ Aggregation method along *projection_axis*. One of ``"max"``,
419
+ ``"min"``, ``"mean"``, ``"median"``, ``"sum"``, ``"absmax"``.
420
+ When *None* (default), trilinear point sampling is used.
421
+ projection_axis : int or None
422
+ World axis to sweep along (0 = x, 1 = y, 2 = z). Required
423
+ when *intensity_projection* is set. Typically derived
424
+ automatically from the view in ``plot_surf``.
425
+
426
+ Returns
427
+ -------
428
+ values : ndarray, shape (n_vertices,)
429
+ Trilinearly interpolated volume values at each vertex position
430
+ (point mode) or aggregated intensity-projection values (sweep
431
+ mode). Values outside the volume extent are 0 in point mode.
432
+ """
433
+ from scipy.ndimage import map_coordinates
434
+
435
+ if isinstance(vol, (str, os.PathLike)):
436
+ vol = nibabel.load(os.fspath(vol))
437
+ data = vol.get_fdata()
438
+ inv_affine = np.linalg.inv(vol.affine)
439
+ ijk = nibabel.affines.apply_affine(inv_affine, world_vertices)
440
+
441
+ # ------------------------------------------------------------------
442
+ # Intensity-projection mode: sweep along one voxel axis per vertex
443
+ # ------------------------------------------------------------------
444
+ if intensity_projection is not None:
445
+ if projection_axis is None:
446
+ raise ValueError(
447
+ "projection_axis is required when intensity_projection is set"
448
+ )
449
+ # Map world axis → voxel axis via the affine
450
+ vox_axis = int(np.argmax(np.abs(vol.affine[projection_axis, :3])))
451
+ n_slices = data.shape[vox_axis]
452
+ n_verts = len(world_vertices)
453
+
454
+ # Build (n_verts, n_slices, 3) sampling grid — keep two coords
455
+ # from the vertex, sweep the third across all slices.
456
+ ijk_sweep = np.broadcast_to(
457
+ ijk[:, np.newaxis, :], (n_verts, n_slices, 3)
458
+ ).copy()
459
+ ijk_sweep[:, :, vox_axis] = np.arange(n_slices)[np.newaxis, :]
460
+
461
+ coords_flat = ijk_sweep.reshape(-1, 3).T # (3, n_verts*n_slices)
462
+ sampled = map_coordinates(
463
+ data, coords_flat, order=1, mode="constant", cval=0
464
+ ).reshape(n_verts, n_slices)
465
+
466
+ if threshold is not None:
467
+ sampled = sampled.astype(float)
468
+ sampled[sampled <= threshold] = np.nan
469
+
470
+ # Suppress "All-NaN slice" warnings — vertices whose entire
471
+ # column is below threshold legitimately become NaN (grey).
472
+ import warnings
473
+ with warnings.catch_warnings():
474
+ warnings.simplefilter("ignore", RuntimeWarning)
475
+ if intensity_projection == "max":
476
+ values = np.nanmax(sampled, axis=1)
477
+ elif intensity_projection == "min":
478
+ values = np.nanmin(sampled, axis=1)
479
+ elif intensity_projection == "mean":
480
+ values = np.nanmean(sampled, axis=1)
481
+ elif intensity_projection == "median":
482
+ values = np.nanmedian(sampled, axis=1)
483
+ elif intensity_projection == "sum":
484
+ values = np.nansum(sampled, axis=1)
485
+ elif intensity_projection == "absmax":
486
+ max_vals = np.nanmax(sampled, axis=1)
487
+ min_vals = np.nanmin(sampled, axis=1)
488
+ values = np.where(
489
+ np.abs(min_vals) > np.abs(max_vals), min_vals, max_vals
490
+ )
491
+ else:
492
+ raise ValueError(
493
+ f"Unsupported intensity_projection: {intensity_projection}"
494
+ )
495
+ return values
496
+
497
+ # ------------------------------------------------------------------
498
+ # Default: trilinear point sampling
499
+ # ------------------------------------------------------------------
500
+ values = map_coordinates(data, ijk.T, order=1, mode="constant", cval=0)
501
+
502
+ if threshold is not None:
503
+ values = values.astype(float)
504
+ values[values <= threshold] = np.nan
505
+ return values
506
+
507
+
508
+ def project_vol_to_surf(
509
+ img,
510
+ surf_mesh,
511
+ radius=3.0,
512
+ interpolation="linear",
513
+ kind="line",
514
+ n_samples=None,
515
+ mask_img=None,
516
+ inner_mesh=None,
517
+ ):
518
+ """
519
+ Project a volume onto a surface mesh using nilearn.
520
+
521
+ This is useful for visualizing cortical statistical maps on a surface.
522
+ For subcortical structures, consider using vol_to_mesh instead.
523
+
524
+ Parameters
525
+ ----------
526
+ img : str, pathlib.Path, or nibabel.Nifti1Image
527
+ Path to a NIfTI file or a loaded nibabel image.
528
+ surf_mesh : str, pathlib.Path, nibabel.GiftiImage, or tuple
529
+ Surface mesh. Can be:
530
+ - Path to a surface file (gifti, freesurfer format)
531
+ - A GiftiImage
532
+ - A tuple (vertices, faces)
533
+ radius : float, default 3.0
534
+ Neighborhood radius in mm for sampling.
535
+ interpolation : {'linear', 'nearest', 'nearest_most_frequent'}, default 'linear'
536
+ Interpolation method for sampling.
537
+ kind : {'auto', 'depth', 'line', 'ball'}, default 'line'
538
+ Sampling strategy.
539
+ n_samples : int or None
540
+ Number of samples per vertex.
541
+ mask_img : Niimg-like or None
542
+ Mask to restrict sampling.
543
+ inner_mesh : str, array, or None
544
+ Inner surface for depth sampling (e.g., white matter surface).
545
+
546
+ Returns
547
+ -------
548
+ texture : ndarray, shape (n_vertices,) or (n_vertices, n_timepoints)
549
+ Surface data values.
550
+
551
+ Examples
552
+ --------
553
+ >>> from bsplot.surface import project_vol_to_surf, plot_surf
554
+ >>> from bsplot.data.surface import get_surface_geometry
555
+ >>> vertices, faces = get_surface_geometry(hemi='lh', template='fsLR')
556
+ >>> texture = project_vol_to_surf('statistical_map.nii.gz', (vertices, faces))
557
+ >>> fig = plot_surf('fsLR', hemi='lh', overlay=texture)
558
+ """
559
+ from nilearn.surface import vol_to_surf as _nilearn_vol_to_surf
560
+
561
+ # Handle tuple input for surf_mesh
562
+ if isinstance(surf_mesh, tuple) and len(surf_mesh) == 2:
563
+ # Create an InMemoryMesh-like object
564
+ from nilearn.surface import InMemoryMesh
565
+
566
+ vertices, faces = surf_mesh
567
+ surf_mesh = InMemoryMesh(vertices, faces)
568
+
569
+ return _nilearn_vol_to_surf(
570
+ img=img,
571
+ surf_mesh=surf_mesh,
572
+ radius=radius,
573
+ interpolation=interpolation,
574
+ kind=kind,
575
+ n_samples=n_samples,
576
+ mask_img=mask_img,
577
+ inner_mesh=inner_mesh,
578
+ )
579
+
580
+
581
+ def normalize_v3(arr):
582
+ """Normalize a numpy array of 3 component vectors shape=(n,3) safely."""
583
+ a = np.asarray(arr, dtype=float)
584
+ norms = np.linalg.norm(a, axis=1, keepdims=True)
585
+ norms = np.where(norms == 0.0, 1.0, norms) # avoid division by zero
586
+ return a / norms
587
+
588
+
589
+ def normal_vectors(vertices, faces):
590
+ norm = np.zeros(vertices.shape, dtype=vertices.dtype)
591
+ tris = vertices[faces]
592
+ n = np.cross(tris[::, 1] - tris[::, 0], tris[::, 2] - tris[::, 0])
593
+ n = normalize_v3(n)
594
+ return n
595
+
596
+
597
+ def vertex_normals(vertices, faces):
598
+ norm = np.zeros(vertices.shape, dtype=vertices.dtype)
599
+ tris = vertices[faces]
600
+ n = np.cross(tris[::, 1] - tris[::, 0], tris[::, 2] - tris[::, 0])
601
+ n = normalize_v3(n)
602
+ norm[faces[:, 0]] += n
603
+ norm[faces[:, 1]] += n
604
+ norm[faces[:, 2]] += n
605
+ return normalize_v3(norm)
606
+
607
+
608
+ def frustum(left, right, bottom, top, znear, zfar):
609
+ M = np.zeros((4, 4), dtype=np.float32)
610
+ M[0, 0] = +2.0 * znear / (right - left)
611
+ M[1, 1] = +2.0 * znear / (top - bottom)
612
+ M[2, 2] = -(zfar + znear) / (zfar - znear)
613
+ M[0, 2] = (right + left) / (right - left)
614
+ M[2, 1] = (top + bottom) / (top - bottom)
615
+ M[2, 3] = -2.0 * znear * zfar / (zfar - znear)
616
+ M[3, 2] = -1.0
617
+ return M
618
+
619
+
620
+ def perspective(fovy, aspect, znear, zfar):
621
+ h = np.tan(0.5 * np.radians(fovy)) * znear
622
+ w = h * aspect
623
+ return frustum(-w, w, -h, h, znear, zfar)
624
+
625
+
626
+ def translate(x, y, z):
627
+ return np.array(
628
+ [[1, 0, 0, x], [0, 1, 0, y], [0, 0, 1, z], [0, 0, 0, 1]], dtype=float
629
+ )
630
+
631
+
632
+ def xrotate(theta):
633
+ t = np.pi * theta / 180
634
+ c, s = np.cos(t), np.sin(t)
635
+ return np.array(
636
+ [[1, 0, 0, 0], [0, c, -s, 0], [0, s, c, 0], [0, 0, 0, 1]], dtype=float
637
+ )
638
+
639
+
640
+ def yrotate(theta):
641
+ t = np.pi * theta / 180
642
+ c, s = np.cos(t), np.sin(t)
643
+ return np.array(
644
+ [[c, 0, s, 0], [0, 1, 0, 0], [-s, 0, c, 0], [0, 0, 0, 1]], dtype=float
645
+ )
646
+
647
+
648
+ def zrotate(theta):
649
+ t = np.pi * theta / 180
650
+ c, s = np.cos(t), np.sin(t)
651
+ return np.array(
652
+ [[c, -s, 0, 0], [s, c, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float
653
+ )
654
+
655
+
656
+ def shading_intensity(vertices, faces, light=np.array([0, 0, 1]), shading=0.7):
657
+ """shade calculation based on light source
658
+ default is vertical light.
659
+ shading controls amount of shading.
660
+ Also saturates so top 20 % of vertices all have max intensity."""
661
+ face_normals = normal_vectors(vertices, faces)
662
+ intensity = np.dot(face_normals, light)
663
+ intensity[np.isnan(intensity)] = 1
664
+ shading = 0.7
665
+ # top 20% all become fully coloured
666
+ _int_range = np.percentile(intensity, 80) - np.min(intensity)
667
+ if _int_range == 0:
668
+ _int_range = 1.0
669
+ intensity = (1 - shading) + shading * (intensity - np.min(intensity)) / _int_range
670
+ # saturate
671
+ intensity[intensity > 1] = 1
672
+
673
+ return intensity
674
+
675
+
676
+ def f7(seq):
677
+ # returns uniques but in order to retain neighbour triangle relationship
678
+ seen = set()
679
+ seen_add = seen.add
680
+ return [x for x in seq if not (x in seen or seen_add(x))]
681
+
682
+
683
+ def get_ring_of_neighbours(island, neighbours, vertex_indices=None, ordered=False):
684
+ """Calculate ring of neighbouring vertices for an island of cortex
685
+ If ordered, then vertices will be returned in connected order"""
686
+ if not vertex_indices:
687
+ vertex_indices = np.arange(len(island))
688
+ if not ordered:
689
+
690
+ neighbours_island = neighbours[island]
691
+ unfiltered_neighbours = []
692
+ for n in neighbours_island:
693
+ unfiltered_neighbours.extend(n)
694
+ unique_neighbours = np.setdiff1d(
695
+ np.unique(unfiltered_neighbours), vertex_indices[island]
696
+ )
697
+ return unique_neighbours
698
+
699
+
700
+ def get_neighbours_from_tris(tris, label=None):
701
+ """Get surface neighbours from tris
702
+ Input: tris
703
+ Returns Nested list. Each list corresponds
704
+ to the ordered neighbours for the given vertex"""
705
+ n_vert = np.max(tris + 1)
706
+ neighbours = [[] for i in range(n_vert)]
707
+ for tri in tris:
708
+ neighbours[tri[0]].extend([tri[1], tri[2]])
709
+ neighbours[tri[2]].extend([tri[0], tri[1]])
710
+ neighbours[tri[1]].extend([tri[2], tri[0]])
711
+ # Get unique neighbours
712
+ for k in range(len(neighbours)):
713
+ if label is not None:
714
+ neighbours[k] = set(neighbours[k]).intersection(label)
715
+ else:
716
+ neighbours[k] = f7(neighbours[k])
717
+ return np.array(neighbours, dtype=object)
718
+
719
+
720
+ def mask_colours(colours, triangles, mask, mask_colour=None):
721
+ """grey out mask"""
722
+ if mask is not None:
723
+ if mask_colour is None:
724
+ mask_colour = np.array([0.86, 0.86, 0.86, 1])
725
+ verts_masked = mask[triangles].any(axis=1)
726
+ colours[verts_masked, :] = mask_colour
727
+ return colours
728
+
729
+
730
+ def adjust_colours_pvals(
731
+ colours,
732
+ pvals,
733
+ triangles,
734
+ mask=None,
735
+ mask_colour=None,
736
+ border_colour=np.array([1.0, 0, 0, 1]),
737
+ ):
738
+ """red ring around clusters and greying out non-significant vertices"""
739
+ colours = mask_colours(colours, triangles, mask, mask_colour)
740
+ neighbours = get_neighbours_from_tris(triangles)
741
+ ring = get_ring_of_neighbours(pvals < 0.05, neighbours)
742
+ if len(ring) > 0:
743
+ ring_label = np.zeros(len(neighbours)).astype(bool)
744
+ ring_label[ring] = 1
745
+ ring = get_ring_of_neighbours(ring_label, neighbours)
746
+ ring_label[ring] = 1
747
+ colours[ring_label[triangles].any(axis=1), :] = border_colour
748
+ grey_out = pvals < 0.05
749
+ verts_grey_out = grey_out[triangles].any(axis=1)
750
+ colours[verts_grey_out, :] = (
751
+ 1.5 * colours[verts_grey_out] + np.array([0.86, 0.86, 0.86, 1])
752
+ ) / 2.5
753
+ return colours
754
+
755
+
756
+ def add_parcellation_colours(
757
+ colours,
758
+ parcel,
759
+ triangles,
760
+ labels=None,
761
+ mask=None,
762
+ filled=False,
763
+ mask_colour=None,
764
+ neighbours=None,
765
+ ):
766
+ """delineate regions"""
767
+ colours = mask_colours(colours, triangles, mask, mask_colour=mask_colour)
768
+ # normalise rois and colors
769
+ rois = list(set(parcel))
770
+ if 0 in rois:
771
+ rois.remove(0)
772
+ if labels is None:
773
+ labels = dict(zip(rois, np.random.rand(len(rois), 4)))
774
+ # remove transparent rois
775
+ # find vertices that delineate rois
776
+ if filled:
777
+ colours = np.zeros_like(colours)
778
+ for l, label in enumerate(rois):
779
+ colours[np.median(parcel[triangles], axis=1) == label] = labels[label]
780
+ return colours
781
+ if neighbours is None:
782
+ neighbours = get_neighbours_from_tris(triangles)
783
+ matrix_colored = np.zeros([len(triangles), len(rois)])
784
+ for l, label in enumerate(rois):
785
+ ring = get_ring_of_neighbours(parcel != label, neighbours)
786
+ if len(ring) > 0:
787
+ ring_label = np.zeros(len(neighbours)).astype(bool)
788
+ ring_label[ring] = 1
789
+ # ring=get_ring_of_neighbours(ring_label,neighbours)
790
+ # ring_label[ring]=1
791
+ # matrix_colored[:,l] = ring_label[triangles].sum(axis=1)
792
+ matrix_colored[:, l] = np.median(
793
+ ring_label[triangles], axis=1
794
+ ) # ring_label[triangles].sum(axis=1)
795
+ # update colours with delineation
796
+ maxis = [max(matrix_colored[i, :]) for i in range(0, len(colours))]
797
+ colours = np.array(
798
+ [
799
+ (
800
+ labels[
801
+ rois[np.random.choice(np.where(matrix_colored[i, :] == maxi)[0])]
802
+ ]
803
+ if maxi != 0
804
+ else colours[i]
805
+ )
806
+ for i, maxi in enumerate(maxis)
807
+ ]
808
+ )
809
+ return colours
810
+
811
+
812
+ def adjust_colours_alpha(colours, alpha):
813
+ """grey out vertices according to scalar"""
814
+ # rescale alpha to 0.2-1.0
815
+ alpha_rescaled = 0.1 + 0.9 * (alpha - np.min(alpha)) / (
816
+ np.max(alpha) - np.min(alpha)
817
+ )
818
+ colours = (alpha_rescaled * colours.T).T + (
819
+ (1 - alpha_rescaled) * np.array([0.86, 0.86, 0.86, 1]).reshape(-1, 1)
820
+ ).T
821
+ colours = np.clip(colours, 0, 1)
822
+ return colours
823
+
824
+
825
+ def frontback(T):
826
+ """
827
+ Sort front and back facing triangles
828
+ Parameters:
829
+ -----------
830
+ T : (n,3) array
831
+ Triangles to sort
832
+ Returns:
833
+ --------
834
+ front and back facing triangles as (n1,3) and (n2,3) arrays (n1+n2=n)
835
+ """
836
+ Z = (
837
+ (T[:, 1, 0] - T[:, 0, 0]) * (T[:, 1, 1] + T[:, 0, 1])
838
+ + (T[:, 2, 0] - T[:, 1, 0]) * (T[:, 2, 1] + T[:, 1, 1])
839
+ + (T[:, 0, 0] - T[:, 2, 0]) * (T[:, 0, 1] + T[:, 2, 1])
840
+ )
841
+ return Z < 0, Z >= 0
842
+
843
+
844
+ def normalized(a, axis=-1, order=2):
845
+ l2 = np.atleast_1d(np.linalg.norm(a, order, axis))
846
+ l2[l2 == 0] = 1
847
+ return a / np.expand_dims(l2, axis)
848
+
849
+
850
+ # ----------------------
851
+ # Internal helper utilities
852
+ # ----------------------
853
+
854
+
855
+ def _resolve_surface_geometry(
856
+ surface,
857
+ *,
858
+ hemi: str,
859
+ surface_suffix: str | None,
860
+ surface_density: str | None,
861
+ surface_kwargs: dict | None,
862
+ ):
863
+ """Resolve vertices and faces from various surface specifications.
864
+
865
+ Accepts:
866
+ - GiftiImage
867
+ - File path to a surface (gifti)
868
+ - Template name string (e.g., "fsLR", "fsaverage") and fetches via get_surface_geometry
869
+
870
+ Returns (vertices, faces).
871
+ """
872
+ if surface_kwargs is None:
873
+ surface_kwargs = {}
874
+
875
+ # Already provided explicit vertices/faces handled by caller
876
+ loaded_vertices = None
877
+ loaded_faces = None
878
+
879
+ # File path (accept str and os.PathLike)
880
+ if isinstance(surface, (str, os.PathLike)) and os.path.isfile(os.fspath(surface)):
881
+ path_str = os.fspath(surface)
882
+ img = nibabel.load(path_str)
883
+ loaded_vertices = img.get_arrays_from_intent(
884
+ nibabel.nifti1.intent_codes["NIFTI_INTENT_POINTSET"]
885
+ )[0].data
886
+ loaded_faces = img.get_arrays_from_intent(
887
+ nibabel.nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"]
888
+ )[0].data
889
+ return loaded_vertices, loaded_faces
890
+
891
+ # Template name (only for plain strings)
892
+ if isinstance(surface, str) and not os.path.isfile(surface):
893
+ from bsplot.data.surface import get_surface_geometry
894
+
895
+ tpl = surface
896
+ suffix = (
897
+ surface_kwargs.get("suffix")
898
+ or surface_kwargs.get("surface_suffix")
899
+ or surface_suffix
900
+ )
901
+ density = (
902
+ surface_kwargs.get("density")
903
+ or surface_kwargs.get("surface_density")
904
+ or surface_density
905
+ )
906
+ if suffix is None:
907
+ suffix = "midthickness" if tpl.lower() == "fslr" else "pial"
908
+ if density is None:
909
+ if tpl.lower() == "fslr":
910
+ density = "32k"
911
+ elif tpl.lower() == "fsaverage":
912
+ density = "164k"
913
+ geom_kwargs = dict(template=tpl, suffix=suffix, hemi=hemi)
914
+ if density is not None:
915
+ geom_kwargs["density"] = density
916
+ if surface_kwargs.get("desc") is not None:
917
+ geom_kwargs["desc"] = surface_kwargs.get("desc")
918
+ if surface_kwargs.get("extension") is not None:
919
+ geom_kwargs["extension"] = surface_kwargs.get("extension")
920
+ return get_surface_geometry(**geom_kwargs)
921
+
922
+ # Gifti image
923
+ if isinstance(surface, nibabel.gifti.gifti.GiftiImage):
924
+ loaded_vertices = surface.get_arrays_from_intent(
925
+ nibabel.nifti1.intent_codes["NIFTI_INTENT_POINTSET"]
926
+ )[0].data
927
+ loaded_faces = surface.get_arrays_from_intent(
928
+ nibabel.nifti1.intent_codes["NIFTI_INTENT_TRIANGLE"]
929
+ )[0].data
930
+ return loaded_vertices, loaded_faces
931
+
932
+ raise ValueError(
933
+ "Could not determine surface geometry. Provide a GiftiImage, a valid surface filepath, a template name (e.g., 'fsLR'/'fsaverage') with hemisphere, or explicit vertices/faces."
934
+ )
935
+
936
+
937
+ def _map_view_to_rotations(view, hemi, x_rotate, z_rotate):
938
+ """Map view string or numeric to yaw (view), pitch (x_rotate), roll (z_rotate), and a view token.
939
+
940
+ Preserves legacy behavior and anatomical conventions used here.
941
+ """
942
+ view_token = None
943
+ if isinstance(view, str):
944
+ v = view.lower()
945
+ if v in ("top", "dorsal", "superior", "axial", "horizontal"):
946
+ view = 0.0
947
+ x_rotate = 0.0
948
+ view_token = "top"
949
+ elif v in ("bottom", "ventral", "inferior"):
950
+ view = 0.0
951
+ x_rotate = 180.0
952
+ z_rotate = 180.0
953
+ view_token = "bottom"
954
+ elif v in ("front", "frontal", "anterior", "coronal"):
955
+ view = 0.0
956
+ x_rotate = 90.0
957
+ z_rotate = 180.0
958
+ view_token = "front"
959
+ elif v in ("back", "posterior"):
960
+ view = 0.0
961
+ x_rotate = 270.0
962
+ view_token = "back"
963
+ elif v in ("lateral", "medial", "sagittal"):
964
+ if v == "sagittal":
965
+ # Sagittal view: show left side for lh, right side for rh
966
+ if hemi == "lh":
967
+ view = 90.0
968
+ elif hemi == "rh":
969
+ view = 270.0
970
+ else:
971
+ view = 90.0
972
+ view_token = "lateral"
973
+ else:
974
+ if hemi == "lh":
975
+ view = 90.0 if v == "lateral" else 270.0
976
+ elif hemi == "rh":
977
+ view = 270.0 if v == "lateral" else 90.0
978
+ else:
979
+ view = 90.0 if v == "lateral" else 270.0
980
+ view_token = v
981
+ else:
982
+ try:
983
+ view = float(view)
984
+ except Exception:
985
+ raise ValueError(
986
+ f"Unknown view '{view}'. Use one of lateral/medial/sagittal, top/dorsal/superior/axial/horizontal, bottom/ventral/inferior, front/frontal/anterior/coronal, back/posterior, or a numeric yaw angle."
987
+ )
988
+ return view, x_rotate, z_rotate, view_token
989
+
990
+
991
+ def _compute_shading_intensity(
992
+ proj_vertices, F, view_token, view, z_rotate, x_rotate
993
+ ):
994
+ """Compute per-face shading intensity with view-dependent light.
995
+
996
+ For lateral/medial views we keep the historical behaviour where the
997
+ light is rotated with the camera (good for side views). For top,
998
+ front, back and arbitrary yaw views we keep the light roughly aligned
999
+ with the viewer so that sulci/gyri remain well shaded irrespective of
1000
+ the rotation, which improves those projections.
1001
+ """
1002
+
1003
+ # Lateral / medial: original behaviour – light rotates with camera
1004
+ if view_token in ("lateral", "medial"):
1005
+ light = np.array([0, 0, 1, 1]) @ yrotate(z_rotate)
1006
+
1007
+ else:
1008
+ # Keep light fixed in (approximate) camera space for other views.
1009
+ # Use a slightly off-vertical direction so structures are shaded
1010
+ # rather than completely flat.
1011
+ if view_token == "top":
1012
+ # Oblique light from front-left with minimal z-component to
1013
+ # create strong shadows in sulci when viewing from dorsal.
1014
+ light = np.array([0.6, 0.75, 0.25, 1.0])
1015
+ elif view_token == "bottom":
1016
+ # Oblique light from front-left with minimal z-component to
1017
+ # reveal sulcal structure when viewing from ventral.
1018
+ light = np.array([0.6, 0.75, -0.25, 1.0])
1019
+ elif view_token == "front":
1020
+ light = np.array([0.0, 0.0, 1.0, 1.0])
1021
+ elif view_token == "back":
1022
+ light = np.array([0.0, 0.0, -1.0, 1.0])
1023
+ else:
1024
+ # Generic numeric yaw or unknown token: pleasant oblique light
1025
+ light = np.array([0.3, 0.3, 0.9, 1.0])
1026
+
1027
+ intensity = shading_intensity(proj_vertices, F, light=light[:3], shading=0.7)
1028
+ return intensity, z_rotate
1029
+
1030
+
1031
+ def plot_surf(
1032
+ surface: nibabel.gifti.gifti.GiftiImage | str | list | tuple = None,
1033
+ overlay=None,
1034
+ overlay_volume=None,
1035
+ intensity_projection=None,
1036
+ sampling_threshold=None,
1037
+ vertices=None,
1038
+ faces=None,
1039
+ ax=None,
1040
+ parcellated=False,
1041
+ hemi="lh",
1042
+ view="lateral",
1043
+ cmap="viridis",
1044
+ color=None, # Base surface color when no overlay
1045
+ label=False,
1046
+ vmax=None,
1047
+ vmin=None,
1048
+ x_rotate=270,
1049
+ pvals=None,
1050
+ mask=None,
1051
+ arrows=None,
1052
+ arrow_subset=None,
1053
+ arrow_size=0.5,
1054
+ arrow_colours=None,
1055
+ arrow_head=0.05,
1056
+ arrow_width=0.001,
1057
+ mask_colour=None,
1058
+ transparency=1,
1059
+ alpha=None, # Alias for transparency
1060
+ show_back=False,
1061
+ border_colour=np.array([1, 0, 0, 1]),
1062
+ alpha_colour=None,
1063
+ flat_map=False,
1064
+ z_rotate=0,
1065
+ neighbours=None,
1066
+ parcel=None,
1067
+ parcel_cmap=None,
1068
+ filled_parcels=False,
1069
+ norm=None,
1070
+ world_coords=True,
1071
+ affine=None,
1072
+ flip_x=False, # Flip x-axis to match slice neurological convention
1073
+ threshold=None, # Values at or below threshold are shown in grey
1074
+ threshold_color=None, # Color for below-threshold regions (default: grey)
1075
+ # NEW: allow direct template names ("fsLR", "fsaverage")
1076
+ surface_suffix: str | None = None,
1077
+ surface_density: str | None = None,
1078
+ surface_kwargs: dict | None = None,
1079
+ # NEW: additional surfaces for depth-correct rendering
1080
+ additional_surfaces: list | None = None,
1081
+ additional_overlays: list | None = None,
1082
+ additional_colors: list | None = None,
1083
+ additional_alphas: list | None = None,
1084
+ additional_cmaps: list | None = None,
1085
+ additional_vmins: list | None = None,
1086
+ additional_vmaxs: list | None = None,
1087
+ # NEW: separate styling for faces and vertices
1088
+ faces_kwargs: dict | None = None,
1089
+ vertices_kwargs: dict | None = None,
1090
+ # NEW: decimate surface mesh before rendering
1091
+ surface_decimate: float | None = None,
1092
+ # PRIVATE: shared bounding box for list-of-surfaces path
1093
+ # (vmin_3d, vmax_3d) — ensures all meshes share one normalization
1094
+ _shared_bbox=None,
1095
+ **kwargs,
1096
+ ):
1097
+ """Plot a surface mesh as a depth-sorted 2D projection.
1098
+
1099
+ Parameters
1100
+ ----------
1101
+ surface : GiftiImage, str, list, tuple, or dict
1102
+ Surface mesh(es) to render. Accepts a single GiftiImage, a file
1103
+ path, a template name (e.g. ``"fsLR"``), a list/tuple of surfaces
1104
+ (rendered depth-correctly in one PolyCollection), or a dict
1105
+ mapping labels to surfaces (e.g. from ``vol_to_mesh(labels=...)``).
1106
+ overlay : array-like or list of array-like, optional
1107
+ Per-vertex data to color-map onto the surface. When *surface* is a
1108
+ list, *overlay* should be a list of equal length.
1109
+ overlay_volume : str, path, or nibabel image, optional
1110
+ A NIfTI volume to project onto each mesh by trilinear sampling at
1111
+ world-coordinate vertex positions (``map_coordinates``). Populates
1112
+ *overlay* automatically. Ignored when *overlay* is provided.
1113
+ intensity_projection : str, optional
1114
+ Aggregation method for sampling ``overlay_volume`` along the
1115
+ viewing axis instead of at exact vertex positions. Mirrors
1116
+ ``plot_slice(intensity_projection=...)``. Supported values:
1117
+ ``"max"``, ``"min"``, ``"mean"``, ``"median"``, ``"sum"``,
1118
+ ``"absmax"``. The projection axis is derived from *view*
1119
+ (lateral → x, frontal → y, top → z). Requires a named view.
1120
+ sampling_threshold : float, optional
1121
+ Applied during ``overlay_volume`` sampling: vertex values at or
1122
+ below this threshold are set to NaN so they appear grey and do not
1123
+ affect the colormap range. Falls back to *threshold* when not set.
1124
+ Use this when *threshold* alone produces poor contrast because the
1125
+ colormap spans from 0 to max instead of from *threshold* to max.
1126
+ threshold : float, optional
1127
+ Rendering-time threshold applied after colormapping. Faces where
1128
+ any vertex value is at or below *threshold* are shown in grey
1129
+ (or *threshold_color*). When ``overlay_volume`` is used and
1130
+ *sampling_threshold* is not set, *threshold* is also used as the
1131
+ sampling threshold.
1132
+ """
1133
+ # Handle alpha as alias for transparency
1134
+ if alpha is not None:
1135
+ transparency = alpha
1136
+
1137
+ if isinstance(cmap, (list, tuple)):
1138
+ cmap = [Colormap(c) if isinstance(c, str) else c for c in cmap]
1139
+ elif isinstance(cmap, str):
1140
+ cmap = Colormap(cmap)
1141
+
1142
+ # Unpack additional_surfaces dict → list, extracting labels and colors
1143
+ _additional_labels = None
1144
+ if isinstance(additional_surfaces, dict):
1145
+ _additional_labels = list(additional_surfaces.keys())
1146
+ # Auto-apply FreeSurfer LUT colors when available (LabeledMeshes)
1147
+ if (
1148
+ additional_colors is None
1149
+ and hasattr(additional_surfaces, "colors")
1150
+ and additional_surfaces.colors
1151
+ ):
1152
+ additional_colors = [
1153
+ additional_surfaces.colors.get(k, None)
1154
+ for k in additional_surfaces
1155
+ ]
1156
+ if all(c is None for c in additional_colors):
1157
+ additional_colors = None
1158
+ additional_surfaces = list(additional_surfaces.values())
1159
+
1160
+ # Normalize hemi="both" to ["lh", "rh"]
1161
+ if isinstance(hemi, str) and hemi.lower() == "both":
1162
+ hemi = ["lh", "rh"]
1163
+
1164
+ # Support hemi as a list (e.g., ["lh", "rh"]) to merge both hemispheres
1165
+ # into a single combined mesh for proper depth-correct rendering
1166
+ if isinstance(hemi, (list, tuple)) and not isinstance(surface, (list, tuple)):
1167
+ hemis_list = list(hemi)
1168
+
1169
+ # Load and merge geometry from each hemisphere
1170
+ all_vertices = []
1171
+ all_faces = []
1172
+ all_overlays = []
1173
+ _layered_overlays = None # For multi-layer overlays per hemisphere
1174
+ vertex_offset = 0
1175
+
1176
+ for h in hemis_list:
1177
+ h_normalized = "lh" if h.lower().startswith("l") else "rh"
1178
+ h_verts, h_faces = _resolve_surface_geometry(
1179
+ surface,
1180
+ hemi=h_normalized,
1181
+ surface_suffix=surface_suffix,
1182
+ surface_density=surface_density,
1183
+ surface_kwargs=surface_kwargs,
1184
+ )
1185
+ all_vertices.append(h_verts)
1186
+ all_faces.append(h_faces + vertex_offset)
1187
+ vertex_offset += len(h_verts)
1188
+
1189
+ # Handle overlay if provided (must be list/tuple matching hemis)
1190
+ if overlay is not None:
1191
+ if isinstance(overlay, (list, tuple)):
1192
+ h_idx = hemis_list.index(h)
1193
+ h_overlay = overlay[h_idx]
1194
+ if isinstance(h_overlay, (list, tuple)):
1195
+ # Multi-layer per hemisphere: each element is a layer
1196
+ if _layered_overlays is None:
1197
+ _layered_overlays = [[] for _ in range(len(h_overlay))]
1198
+ for layer_i, layer_arr in enumerate(h_overlay):
1199
+ _layered_overlays[layer_i].append(layer_arr)
1200
+ else:
1201
+ all_overlays.append(h_overlay)
1202
+ else:
1203
+ # Single overlay - assume it's for a single hemi (use as-is)
1204
+ all_overlays.append(overlay)
1205
+
1206
+ # Combine geometry
1207
+ vertices = np.vstack(all_vertices).astype(np.float32)
1208
+ faces = np.vstack(all_faces).astype(np.int32)
1209
+
1210
+ # Combine overlays if provided
1211
+ if _layered_overlays is not None:
1212
+ # Multi-layer: concatenate each layer across hemispheres
1213
+ overlay = [np.concatenate(layers) for layers in _layered_overlays]
1214
+ elif all_overlays:
1215
+ overlay = np.concatenate(all_overlays)
1216
+
1217
+ # Use first hemi for view calculations (lateral/medial)
1218
+ hemi = hemis_list[0]
1219
+ hemi = "lh" if hemi.lower().startswith("l") else "rh"
1220
+
1221
+ # Continue with merged geometry (skip surface resolution below)
1222
+ # vertices and faces are already set
1223
+
1224
+ # Support rendering multiple surfaces into a single axes.
1225
+ # Accept a dict (e.g. from vol_to_mesh with labels): keys → labels, values → surfaces.
1226
+ if isinstance(surface, dict):
1227
+ if label is False or label is None:
1228
+ label = list(surface.keys())
1229
+ # Auto-apply FreeSurfer LUT colors when available (LabeledMeshes)
1230
+ if color is None and hasattr(surface, "colors") and surface.colors:
1231
+ color = [
1232
+ surface.colors.get(k, None) for k in surface.keys()
1233
+ ]
1234
+ # Fall back to None if no key matched
1235
+ if all(c is None for c in color):
1236
+ color = None
1237
+ surface = list(surface.values())
1238
+
1239
+ if isinstance(surface, (list, tuple)):
1240
+ n = len(surface)
1241
+ # Normalize overlays to list length n
1242
+ if isinstance(overlay, (list, tuple)):
1243
+ overlays_list = list(overlay)
1244
+ elif (
1245
+ (overlay is not None)
1246
+ and hasattr(overlay, "shape")
1247
+ and hasattr(overlay, "__getitem__")
1248
+ ):
1249
+ try:
1250
+ overlays_list = [overlay[i] for i in range(len(overlay))]
1251
+ except Exception:
1252
+ overlays_list = [overlay] * n
1253
+ else:
1254
+ overlays_list = [overlay] * n
1255
+ if len(overlays_list) != n:
1256
+ raise ValueError(
1257
+ f"overlay must have first dimension equal to number of surfaces ({n}), got {len(overlays_list)}"
1258
+ )
1259
+
1260
+ # Normalize hemis to per-surface list if provided as sequence
1261
+ if isinstance(hemi, (list, tuple)):
1262
+ hemis_list = list(hemi)
1263
+ if len(hemis_list) != n:
1264
+ raise ValueError(
1265
+ f"hemi must be a string or a sequence with length equal to number of surfaces ({n})"
1266
+ )
1267
+ else:
1268
+ hemis_list = [hemi] * n
1269
+
1270
+ # Optional per-surface vertices/faces
1271
+ vertices_list = None
1272
+ faces_list = None
1273
+ if isinstance(vertices, (list, tuple)):
1274
+ if len(vertices) != n:
1275
+ raise ValueError("vertices list must match number of surfaces")
1276
+ vertices_list = list(vertices)
1277
+ if isinstance(faces, (list, tuple)):
1278
+ if len(faces) != n:
1279
+ raise ValueError("faces list must match number of surfaces")
1280
+ faces_list = list(faces)
1281
+
1282
+ # Sample overlay_volume at each mesh's world-coordinate vertices
1283
+ if overlay_volume is not None and all(o is None for o in overlays_list):
1284
+ _vol = (
1285
+ nibabel.load(overlay_volume)
1286
+ if isinstance(overlay_volume, (str, os.PathLike))
1287
+ else overlay_volume
1288
+ )
1289
+ # Derive projection axis from view when intensity_projection is set
1290
+ _proj_axis = None
1291
+ if intensity_projection is not None:
1292
+ _proj_axis = _PROJECTION_AXES.get(
1293
+ view.lower() if isinstance(view, str) else None
1294
+ )
1295
+ if _proj_axis is None:
1296
+ raise ValueError(
1297
+ "intensity_projection requires a named view "
1298
+ "(e.g. 'lateral', 'frontal', 'top') to determine "
1299
+ "the projection axis."
1300
+ )
1301
+ for _ov_i in range(n):
1302
+ _v_i = None if vertices_list is None else vertices_list[_ov_i]
1303
+ if _v_i is None:
1304
+ _v_i, _ = _resolve_surface_geometry(
1305
+ surface[_ov_i],
1306
+ hemi=hemis_list[_ov_i],
1307
+ surface_suffix=surface_suffix,
1308
+ surface_density=surface_density,
1309
+ surface_kwargs=surface_kwargs,
1310
+ )
1311
+ _v_i = np.asarray(_v_i, dtype=np.float32)
1312
+ if affine is not None and affine.shape == (4, 4):
1313
+ _v_i = nibabel.affines.apply_affine(affine, _v_i)
1314
+ overlays_list[_ov_i] = _sample_volume_at_vertices(
1315
+ _vol, _v_i,
1316
+ threshold=sampling_threshold if sampling_threshold is not None else threshold,
1317
+ intensity_projection=intensity_projection,
1318
+ projection_axis=_proj_axis,
1319
+ )
1320
+
1321
+ # Normalize per-surface styling parameters: if a list is provided,
1322
+ # index per surface; if scalar/None, broadcast to all surfaces.
1323
+ def _broadcast(param, name):
1324
+ if isinstance(param, (list, tuple)):
1325
+ if len(param) != n:
1326
+ raise ValueError(
1327
+ f"{name} list length ({len(param)}) must match number of surfaces ({n})"
1328
+ )
1329
+ return list(param)
1330
+ return [param] * n
1331
+
1332
+ colors_list = _broadcast(color, "color")
1333
+ cmaps_list = _broadcast(cmap, "cmap")
1334
+ transparency_list = _broadcast(transparency, "alpha/transparency")
1335
+ vmin_list = _broadcast(vmin, "vmin")
1336
+ vmax_list = _broadcast(vmax, "vmax")
1337
+ labels_list = _broadcast(label, "label")
1338
+
1339
+ # faces_kwargs: list of dicts → per-surface; single dict → broadcast
1340
+ if isinstance(faces_kwargs, list):
1341
+ if len(faces_kwargs) != n:
1342
+ raise ValueError(
1343
+ f"faces_kwargs list length ({len(faces_kwargs)}) must match "
1344
+ f"number of surfaces ({n})"
1345
+ )
1346
+ fk_list = list(faces_kwargs)
1347
+ else:
1348
+ fk_list = [faces_kwargs] * n
1349
+
1350
+ # ------------------------------------------------------------------
1351
+ # Depth-correct rendering: use the first surface as the main surface
1352
+ # and pass the rest through `additional_surfaces` so all triangles
1353
+ # are merged into one PolyCollection and depth-sorted together.
1354
+ # ------------------------------------------------------------------
1355
+ created_fig = False
1356
+ if ax is None:
1357
+ fig, ax = plt.subplots()
1358
+ created_fig = True
1359
+ else:
1360
+ fig = ax.figure
1361
+
1362
+ # First surface is the "main" one
1363
+ main_surf = surface[0]
1364
+ main_verts = None if vertices_list is None else vertices_list[0]
1365
+ main_faces = None if faces_list is None else faces_list[0]
1366
+
1367
+ # Remaining surfaces go through the additional_surfaces path
1368
+ add_surfs = list(surface[1:]) if n > 1 else []
1369
+ add_overlays = list(overlays_list[1:]) if n > 1 else []
1370
+ add_colors = list(colors_list[1:]) if n > 1 else []
1371
+ add_alphas = list(transparency_list[1:]) if n > 1 else []
1372
+ add_cmaps = list(cmaps_list[1:]) if n > 1 else []
1373
+ add_vmins = list(vmin_list[1:]) if n > 1 else []
1374
+ add_vmaxs = list(vmax_list[1:]) if n > 1 else []
1375
+
1376
+ # Merge any user-supplied additional_surfaces into the list
1377
+ _add_labels_for_legend = []
1378
+ _add_colors_for_legend = []
1379
+ if additional_surfaces is not None and len(additional_surfaces) > 0:
1380
+ add_surfs.extend(additional_surfaces)
1381
+ _n_user_add = len(additional_surfaces)
1382
+ add_overlays.extend(
1383
+ additional_overlays if additional_overlays else [None] * _n_user_add
1384
+ )
1385
+ _user_add_colors = (
1386
+ additional_colors if additional_colors else [None] * _n_user_add
1387
+ )
1388
+ add_colors.extend(_user_add_colors)
1389
+ add_alphas.extend(
1390
+ additional_alphas if additional_alphas else [1] * _n_user_add
1391
+ )
1392
+ add_cmaps.extend(
1393
+ additional_cmaps if additional_cmaps else [None] * _n_user_add
1394
+ )
1395
+ add_vmins.extend(
1396
+ additional_vmins if additional_vmins else [None] * _n_user_add
1397
+ )
1398
+ add_vmaxs.extend(
1399
+ additional_vmaxs if additional_vmaxs else [None] * _n_user_add
1400
+ )
1401
+ # Track labels from dict-unpacked additional_surfaces
1402
+ if _additional_labels is not None:
1403
+ _add_labels_for_legend = list(_additional_labels)
1404
+ _add_colors_for_legend = list(_user_add_colors)
1405
+
1406
+ # Convert empty lists to None for downstream compatibility
1407
+ if not add_surfs:
1408
+ add_surfs = None
1409
+ add_overlays = None
1410
+ add_colors = None
1411
+ add_alphas = None
1412
+ add_cmaps = None
1413
+ add_vmins = None
1414
+ add_vmaxs = None
1415
+
1416
+ # Compute shared bounding box across ALL meshes so that the
1417
+ # main surface and all additional surfaces share one normalization.
1418
+ if _shared_bbox is None:
1419
+ all_verts = []
1420
+ _all_sources = list(surface)
1421
+ if add_surfs is not None:
1422
+ _all_sources.extend(add_surfs)
1423
+ for _i, _s in enumerate(_all_sources):
1424
+ _v_i = None
1425
+ if _i < n and vertices_list is not None:
1426
+ _v_i = vertices_list[_i]
1427
+ if _v_i is None:
1428
+ _h = hemis_list[_i] if _i < len(hemis_list) else hemi
1429
+ _v_i, _ = _resolve_surface_geometry(
1430
+ _s,
1431
+ hemi=_h,
1432
+ surface_suffix=surface_suffix,
1433
+ surface_density=surface_density,
1434
+ surface_kwargs=surface_kwargs,
1435
+ )
1436
+ _v_i = np.asarray(_v_i, dtype=np.float32)
1437
+ if affine is not None and affine.shape == (4, 4):
1438
+ _v_i = (np.c_[_v_i, np.ones(len(_v_i))] @ affine.T)[:, :3]
1439
+ all_verts.append(_v_i)
1440
+ _all = np.vstack(all_verts)
1441
+ _shared_bbox = (_all.min(0), _all.max(0))
1442
+
1443
+ result = plot_surf(
1444
+ main_surf,
1445
+ overlay=overlays_list[0],
1446
+ vertices=main_verts,
1447
+ faces=main_faces,
1448
+ ax=ax,
1449
+ parcellated=parcellated,
1450
+ hemi=hemis_list[0],
1451
+ view=view,
1452
+ cmap=cmaps_list[0],
1453
+ color=colors_list[0],
1454
+ label=False, # legend proxies are added below for all surfaces
1455
+ vmax=vmax_list[0],
1456
+ vmin=vmin_list[0],
1457
+ x_rotate=x_rotate,
1458
+ pvals=pvals,
1459
+ mask=mask,
1460
+ arrows=arrows,
1461
+ arrow_subset=arrow_subset,
1462
+ arrow_size=arrow_size,
1463
+ arrow_colours=arrow_colours,
1464
+ arrow_head=arrow_head,
1465
+ arrow_width=arrow_width,
1466
+ mask_colour=mask_colour,
1467
+ transparency=transparency_list[0],
1468
+ faces_kwargs=fk_list,
1469
+ show_back=show_back,
1470
+ border_colour=border_colour,
1471
+ alpha_colour=alpha_colour,
1472
+ flat_map=flat_map,
1473
+ z_rotate=z_rotate,
1474
+ neighbours=neighbours,
1475
+ parcel=parcel,
1476
+ parcel_cmap=parcel_cmap,
1477
+ filled_parcels=filled_parcels,
1478
+ norm=norm,
1479
+ world_coords=world_coords,
1480
+ affine=affine,
1481
+ flip_x=flip_x,
1482
+ threshold=threshold,
1483
+ threshold_color=threshold_color,
1484
+ surface_suffix=surface_suffix,
1485
+ surface_density=surface_density,
1486
+ surface_kwargs=surface_kwargs,
1487
+ additional_surfaces=add_surfs,
1488
+ additional_overlays=add_overlays,
1489
+ additional_colors=add_colors,
1490
+ additional_alphas=add_alphas,
1491
+ additional_cmaps=add_cmaps,
1492
+ additional_vmins=add_vmins,
1493
+ additional_vmaxs=add_vmaxs,
1494
+ _shared_bbox=_shared_bbox,
1495
+ **kwargs,
1496
+ )
1497
+
1498
+ # Add legend proxy patches for all surfaces that have labels
1499
+ for i in range(n):
1500
+ lbl = labels_list[i]
1501
+ if lbl and lbl is not False:
1502
+ col = colors_list[i]
1503
+ if col is None:
1504
+ col = (0.86, 0.86, 0.86, 1.0)
1505
+ import matplotlib.patches as _mp
1506
+ import matplotlib.colors as _mc
1507
+ _proxy = _mp.Rectangle(
1508
+ (0, 0), 0, 0,
1509
+ facecolor=_mc.to_rgba(col),
1510
+ label=lbl,
1511
+ )
1512
+ ax.add_patch(_proxy)
1513
+
1514
+ # Add legend proxies for dict-unpacked additional surfaces
1515
+ if _add_labels_for_legend:
1516
+ import matplotlib.patches as _mp
1517
+ import matplotlib.colors as _mc
1518
+ for _al_i, _al_lbl in enumerate(_add_labels_for_legend):
1519
+ _al_col = (
1520
+ _add_colors_for_legend[_al_i]
1521
+ if _al_i < len(_add_colors_for_legend)
1522
+ else None
1523
+ )
1524
+ if _al_col is None:
1525
+ _al_col = (0.86, 0.86, 0.86, 1.0)
1526
+ _proxy = _mp.Rectangle(
1527
+ (0, 0), 0, 0,
1528
+ facecolor=_mc.to_rgba(_al_col),
1529
+ label=_al_lbl,
1530
+ )
1531
+ ax.add_patch(_proxy)
1532
+
1533
+ ax.set_aspect("equal")
1534
+
1535
+ if created_fig:
1536
+ plt.close(fig)
1537
+ return fig
1538
+ return result
1539
+ else:
1540
+ hemi = "lh" if hemi.lower().startswith("l") else "rh"
1541
+ # SINGLE-SURFACE CODE PATH BELOW
1542
+ _is_true_flatmap = False
1543
+ if flat_map and isinstance(surface, str) and not os.path.isfile(surface):
1544
+ # Use a true cortical flatmap mesh if available for this template
1545
+ from bsplot.data.surface import get_flat_surface_geometry
1546
+
1547
+ try:
1548
+ flat_vertices, flat_faces = get_flat_surface_geometry(
1549
+ template=surface, hemi=hemi
1550
+ )
1551
+ # Resolve the original surface only if we need overlay vertex count
1552
+ if vertices is None or faces is None:
1553
+ vertices, faces = _resolve_surface_geometry(
1554
+ surface,
1555
+ hemi=hemi,
1556
+ surface_suffix=surface_suffix,
1557
+ surface_density=surface_density,
1558
+ surface_kwargs=surface_kwargs,
1559
+ )
1560
+ # Use flat geometry for rendering; overlay is vertex-indexed so it
1561
+ # stays valid as long as the vertex count matches.
1562
+ if flat_vertices.shape[0] == vertices.shape[0]:
1563
+ vertices = flat_vertices
1564
+ faces = flat_faces
1565
+ # Force a top-down view so the flat surface is seen face-on
1566
+ view = "top"
1567
+ x_rotate = 0
1568
+ z_rotate = 0
1569
+ _is_true_flatmap = True
1570
+ else:
1571
+ raise ValueError(
1572
+ f"Flat surface vertex count ({flat_vertices.shape[0]}) does not match "
1573
+ f"surface vertex count ({vertices.shape[0]}) for template '{surface}'."
1574
+ )
1575
+ except ValueError:
1576
+ raise
1577
+
1578
+ if vertices is None or faces is None:
1579
+ vertices, faces = _resolve_surface_geometry(
1580
+ surface,
1581
+ hemi=hemi,
1582
+ surface_suffix=surface_suffix,
1583
+ surface_density=surface_density,
1584
+ surface_kwargs=surface_kwargs,
1585
+ )
1586
+
1587
+ if ax is None:
1588
+ fig, ax = plt.subplots()
1589
+ return_fig = True
1590
+ else:
1591
+ return_fig = False
1592
+ # Map text views to canonical rotations (preserve behavior)
1593
+ view, x_rotate, z_rotate, view_token = _map_view_to_rotations(
1594
+ view, hemi, x_rotate, z_rotate
1595
+ )
1596
+
1597
+ vertices = vertices.astype(np.float32)
1598
+ F = faces.astype(int)
1599
+
1600
+ # Optional: decimate mesh to reduce vertex/face count
1601
+ if surface_decimate is not None and 0 < surface_decimate < 1:
1602
+ decimated = False
1603
+ orig_vertices = vertices.copy() # keep for overlay remapping
1604
+ # Try Open3D quadric decimation (best quality)
1605
+ if not decimated:
1606
+ try:
1607
+ import open3d as o3d
1608
+ mesh = o3d.geometry.TriangleMesh()
1609
+ mesh.vertices = o3d.utility.Vector3dVector(vertices)
1610
+ mesh.triangles = o3d.utility.Vector3iVector(F)
1611
+ mesh.compute_vertex_normals()
1612
+ target = max(int(len(F) * surface_decimate), 4)
1613
+ mesh = mesh.simplify_quadric_decimation(
1614
+ target_number_of_triangles=target
1615
+ )
1616
+ vertices = np.asarray(mesh.vertices, dtype=np.float32)
1617
+ F = np.asarray(mesh.triangles, dtype=int)
1618
+ decimated = True
1619
+ _decimate_method = "open3d"
1620
+ except (ImportError, Exception):
1621
+ pass
1622
+ # Fallback: vertex clustering (spatial binning)
1623
+ if not decimated:
1624
+ # Estimate grid resolution from desired face fraction
1625
+ n_target_verts = max(int(len(vertices) * surface_decimate), 12)
1626
+ bbox_range = vertices.max(0) - vertices.min(0)
1627
+ vol = np.prod(bbox_range + 1e-9)
1628
+ cell_size = (vol / n_target_verts) ** (1.0 / 3.0)
1629
+ # Assign each vertex to a grid cell
1630
+ grid_idx = ((vertices - vertices.min(0)) / cell_size).astype(int)
1631
+ # Unique cell keys
1632
+ keys = (
1633
+ grid_idx[:, 0] * 1000000
1634
+ + grid_idx[:, 1] * 1000
1635
+ + grid_idx[:, 2]
1636
+ )
1637
+ unique_keys, inverse = np.unique(keys, return_inverse=True)
1638
+ # Compute cluster centroids
1639
+ new_verts = np.zeros((len(unique_keys), 3), dtype=np.float32)
1640
+ for i in range(len(unique_keys)):
1641
+ mask = inverse == i
1642
+ new_verts[i] = vertices[mask].mean(axis=0)
1643
+ # Remap faces
1644
+ new_F = inverse[F]
1645
+ # Remove degenerate faces (collapsed edges)
1646
+ valid = (
1647
+ (new_F[:, 0] != new_F[:, 1])
1648
+ & (new_F[:, 1] != new_F[:, 2])
1649
+ & (new_F[:, 0] != new_F[:, 2])
1650
+ )
1651
+ new_F = new_F[valid]
1652
+ # Remove duplicate faces
1653
+ sorted_f = np.sort(new_F, axis=1)
1654
+ _, uidx = np.unique(sorted_f, axis=0, return_index=True)
1655
+ new_F = new_F[uidx]
1656
+ vertices = new_verts
1657
+ F = new_F
1658
+ decimated = True
1659
+ _decimate_method = "clustering"
1660
+
1661
+ # Remap overlay(s) to decimated vertices via nearest-neighbor lookup
1662
+ def _remap_overlay_to_decimated(ov, orig_v, new_v):
1663
+ """Map per-vertex overlay from original to decimated mesh."""
1664
+ if ov is None or not hasattr(ov, '__len__'):
1665
+ return ov
1666
+ if len(ov) == len(new_v):
1667
+ return ov # already matching
1668
+ if len(ov) != len(orig_v):
1669
+ return None # incompatible size
1670
+ from scipy.spatial import cKDTree
1671
+ tree = cKDTree(orig_v)
1672
+ _, nn_idx = tree.query(new_v)
1673
+ return ov[nn_idx]
1674
+
1675
+ if overlay is not None:
1676
+ if isinstance(overlay, list):
1677
+ overlay = [_remap_overlay_to_decimated(o, orig_vertices, vertices) for o in overlay]
1678
+ else:
1679
+ overlay = _remap_overlay_to_decimated(overlay, orig_vertices, vertices)
1680
+
1681
+ # Optional: transform vertices by a provided 4x4 affine (e.g., to MNI space).
1682
+ if affine is not None:
1683
+ if affine.shape == (4, 4):
1684
+ vertices = (np.c_[vertices, np.ones(len(vertices))] @ affine.T)[:, :3]
1685
+ else:
1686
+ raise ValueError("affine must be a 4x4 matrix")
1687
+
1688
+ # Preserve world-space copy for axis extents
1689
+ vertices_world = vertices.copy()
1690
+
1691
+ # Sample overlay_volume at world-coordinate vertices (single-surface path)
1692
+ if overlay_volume is not None and overlay is None:
1693
+ _proj_axis = None
1694
+ if intensity_projection is not None:
1695
+ _proj_axis = _PROJECTION_AXES.get(
1696
+ view.lower() if isinstance(view, str) else None
1697
+ )
1698
+ if _proj_axis is None:
1699
+ raise ValueError(
1700
+ "intensity_projection requires a named view "
1701
+ "(e.g. 'lateral', 'frontal', 'top') to determine "
1702
+ "the projection axis."
1703
+ )
1704
+ overlay = _sample_volume_at_vertices(
1705
+ overlay_volume, vertices_world,
1706
+ threshold=sampling_threshold if sampling_threshold is not None else threshold,
1707
+ intensity_projection=intensity_projection,
1708
+ projection_axis=_proj_axis,
1709
+ )
1710
+
1711
+ # Always normalize for rendering stability
1712
+ # When _shared_bbox is set (list-of-surfaces path), use the shared
1713
+ # center/scale so all meshes keep their relative positions.
1714
+ if _shared_bbox is not None:
1715
+ _bbox_min, _bbox_max = _shared_bbox
1716
+ _center = (_bbox_max + _bbox_min) / 2
1717
+ _scale = max(_bbox_max - _bbox_min)
1718
+ else:
1719
+ _center = (vertices.max(0) + vertices.min(0)) / 2
1720
+ _scale = max(vertices.max(0) - vertices.min(0))
1721
+ # Guard against zero extent (degenerate mesh)
1722
+ if _scale == 0:
1723
+ _scale = 1.0
1724
+ proj_vertices = (vertices - _center) / _scale
1725
+
1726
+ overlays = [overlay] if not isinstance(overlay, list) else overlay
1727
+ n_overlays = len(overlays)
1728
+
1729
+ # Normalize cmap to per-overlay list
1730
+ if isinstance(cmap, (list, tuple)):
1731
+ cmaps = list(cmap)
1732
+ if len(cmaps) != n_overlays:
1733
+ raise ValueError(
1734
+ f"cmap list length ({len(cmaps)}) must match number of overlays ({n_overlays})"
1735
+ )
1736
+ else:
1737
+ cmaps = [cmap] * n_overlays
1738
+
1739
+ # Normalize transparency to per-overlay list
1740
+ if isinstance(transparency, (list, tuple)):
1741
+ transparencies = list(transparency)
1742
+ if len(transparencies) != n_overlays:
1743
+ raise ValueError(
1744
+ f"transparency list length ({len(transparencies)}) must match number of overlays ({n_overlays})"
1745
+ )
1746
+ else:
1747
+ transparencies = [transparency] * n_overlays
1748
+
1749
+ if parcel is not None and parcel.sum() == 0:
1750
+ parcel = None
1751
+
1752
+ # Shading intensity based on normalized geometry
1753
+ if _is_true_flatmap:
1754
+ # Flat surface has uniform normals — skip 3D shading
1755
+ intensity = np.ones(len(F))
1756
+ else:
1757
+ intensity, z_rotate = _compute_shading_intensity(
1758
+ proj_vertices, F, view_token, view, z_rotate, x_rotate
1759
+ )
1760
+
1761
+ collection = None
1762
+ for k, overlay in enumerate(overlays):
1763
+ # If no overlay provided, use a uniform base colour and still apply shading
1764
+ if overlay is None:
1765
+ C = np.ones((len(F), 4), dtype=float)
1766
+ # Use custom color if provided, otherwise default grey
1767
+ if color is not None:
1768
+ import matplotlib.colors as mcolors
1769
+
1770
+ base_color = np.array(mcolors.to_rgba(color))
1771
+ C[:] = base_color
1772
+ else:
1773
+ C[:] = np.array([0.86, 0.86, 0.86, 1.0])
1774
+ # Allow parcel boundaries/fills even without overlay
1775
+ if parcel is not None:
1776
+ C = add_parcellation_colours(
1777
+ C,
1778
+ parcel,
1779
+ F,
1780
+ parcel_cmap,
1781
+ mask,
1782
+ mask_colour=mask_colour,
1783
+ filled=filled_parcels,
1784
+ neighbours=neighbours,
1785
+ )
1786
+ else:
1787
+ # Identify faces where ALL vertices are NaN (before aggregation)
1788
+ face_values = overlay[F] # shape (n_faces, 3)
1789
+ nan_faces = np.all(np.isnan(face_values), axis=1)
1790
+
1791
+ # Per-face colour aggregation (NaN-safe)
1792
+ # For all-NaN faces, temporarily fill with 0 to avoid empty-slice warnings;
1793
+ # these faces are overridden to grey below anyway.
1794
+ face_values_filled = face_values.copy()
1795
+ face_values_filled[nan_faces] = 0.0
1796
+
1797
+ if label:
1798
+ colours = np.nanmedian(face_values_filled, axis=1)
1799
+ elif parcellated:
1800
+ colours = np.nanmax(face_values_filled, axis=1)
1801
+ else:
1802
+ colours = np.nanmean(face_values_filled, axis=1)
1803
+
1804
+ # Normalize to [0,1]
1805
+ if norm is not None:
1806
+ colours = norm(colours)
1807
+ else:
1808
+ if vmax is not None and vmin is not None:
1809
+ colours = (colours - vmin) / (vmax - vmin)
1810
+ colours = np.clip(colours, 0, 1)
1811
+ else:
1812
+ cmin = np.nanmin(colours)
1813
+ cmax = np.nanmax(colours)
1814
+ if cmax > cmin:
1815
+ colours = (colours - cmin) / (cmax - cmin)
1816
+ else:
1817
+ colours = np.zeros_like(colours)
1818
+
1819
+ cmap_func = Colormap(cmaps[k])
1820
+ C = cmap_func(np.clip(colours, 0, 1))
1821
+
1822
+ # Grey out triangles below threshold
1823
+ # Default threshold color is light grey
1824
+ if threshold_color is None:
1825
+ below_thresh_color = np.array([0.86, 0.86, 0.86, 1.0])
1826
+ else:
1827
+ # Convert color to RGBA
1828
+ import matplotlib.colors as mcolors
1829
+ below_thresh_color = np.array(mcolors.to_rgba(threshold_color))
1830
+
1831
+ # Also grey out faces that were NaN
1832
+ C[nan_faces] = below_thresh_color
1833
+
1834
+ # Apply threshold only when explicitly set
1835
+ if threshold is not None:
1836
+ # A triangle is below threshold if ANY of its vertices is at or below threshold
1837
+ is_below_threshold = np.any(overlay[F] <= threshold, axis=1)
1838
+ C[is_below_threshold] = below_thresh_color
1839
+
1840
+ # Optional adjustments
1841
+ if alpha_colour is not None:
1842
+ C = adjust_colours_alpha(C, np.mean(alpha_colour[F], axis=1))
1843
+ if pvals is not None:
1844
+ C = adjust_colours_pvals(
1845
+ C,
1846
+ pvals,
1847
+ F,
1848
+ mask,
1849
+ mask_colour=mask_colour,
1850
+ border_colour=border_colour,
1851
+ )
1852
+ elif mask is not None:
1853
+ C = mask_colours(C, F, mask, mask_colour=mask_colour)
1854
+ if parcel is not None:
1855
+ C = add_parcellation_colours(
1856
+ C,
1857
+ parcel,
1858
+ F,
1859
+ parcel_cmap,
1860
+ mask,
1861
+ mask_colour=mask_colour,
1862
+ filled=filled_parcels,
1863
+ neighbours=neighbours,
1864
+ )
1865
+
1866
+ # Capture pre-shading face color for legend proxy (before shading darkens it)
1867
+ _legend_color = C[0].copy() if (label and label is not False) else None
1868
+
1869
+ # Apply lighting intensity to RGB channels
1870
+ C[:, 0:3] *= intensity[:, None]
1871
+
1872
+ # Perspective MVP for robust drawing
1873
+ MVP = (
1874
+ perspective(25, 1, 1, 100)
1875
+ @ translate(0, 0, -3)
1876
+ @ yrotate(view)
1877
+ @ zrotate(z_rotate)
1878
+ @ xrotate(x_rotate)
1879
+ )
1880
+
1881
+ # Initialize arrow placeholders for type checkers
1882
+ A_base = None
1883
+ A_dir = None
1884
+
1885
+ # Project all vertices
1886
+ V_all = np.c_[proj_vertices, np.ones(len(proj_vertices))] @ MVP.T
1887
+ V_all /= V_all[:, 3:4]
1888
+
1889
+ # Arrow bases/directions
1890
+ if arrows is not None:
1891
+ vertex_normal_orig = vertex_normals(proj_vertices, F)
1892
+ A_base = (
1893
+ np.c_[
1894
+ proj_vertices + vertex_normal_orig * 0.01,
1895
+ np.ones(len(proj_vertices)),
1896
+ ]
1897
+ @ MVP.T
1898
+ )
1899
+ A_base /= A_base[:, 3:4]
1900
+
1901
+ A_dir = np.copy(arrows)
1902
+ max_arrow = np.max(np.linalg.norm(arrows, axis=1))
1903
+ if max_arrow > 0:
1904
+ A_dir = arrow_size * A_dir / max_arrow
1905
+ A_dir = np.c_[A_dir, np.ones(len(A_dir))] @ MVP.T
1906
+ A_dir /= A_dir[:, 3:4]
1907
+
1908
+ # Triangles
1909
+ V = V_all[F]
1910
+ T = V[:, :, :2]
1911
+
1912
+ # If world_coords requested, map projected plane to world-plane extents
1913
+ if world_coords:
1914
+ V_full_proj = V_all # already projected all
1915
+ pxmin, pxmax = V_full_proj[:, 0].min(), V_full_proj[:, 0].max()
1916
+ pymin, pymax = V_full_proj[:, 1].min(), V_full_proj[:, 1].max()
1917
+
1918
+ ROT = (
1919
+ yrotate(view)
1920
+ @ zrotate(z_rotate)
1921
+ @ xrotate(x_rotate)
1922
+ )
1923
+ V_world = np.c_[vertices_world, np.ones(len(vertices_world))] @ ROT.T
1924
+ wxmin, wxmax = V_world[:, 0].min(), V_world[:, 0].max()
1925
+ wymin, wymax = V_world[:, 1].min(), V_world[:, 1].max()
1926
+
1927
+ sx = (wxmax - wxmin) / (pxmax - pxmin) if (pxmax - pxmin) != 0 else 1.0
1928
+ sy = (wymax - wymin) / (pymax - pymin) if (pymax - pymin) != 0 else 1.0
1929
+
1930
+ T[:, :, 0] = wxmin + (T[:, :, 0] - pxmin) * sx
1931
+ T[:, :, 1] = wymin + (T[:, :, 1] - pymin) * sy
1932
+
1933
+ # Flip x-axis around x=0 (brain midline) to match slice neurological convention
1934
+ if flip_x:
1935
+ T[:, :, 0] = -T[:, :, 0]
1936
+
1937
+ if arrows is not None:
1938
+ A_base[:, 0] = wxmin + (A_base[:, 0] - pxmin) * sx
1939
+ A_base[:, 1] = wymin + (A_base[:, 1] - pymin) * sy
1940
+ A_dir[:, 0] = A_dir[:, 0] * sx
1941
+ A_dir[:, 1] = A_dir[:, 1] * sy
1942
+
1943
+ if flip_x:
1944
+ A_base[:, 0] = -A_base[:, 0]
1945
+ A_dir[:, 0] = -A_dir[:, 0]
1946
+
1947
+ # Depth sort and cull
1948
+ Z = -V[:, :, 2].mean(axis=1)
1949
+ front, back = frontback(T)
1950
+ if show_back is False:
1951
+ T = T[front]
1952
+ s_C = C[front]
1953
+ Z = Z[front]
1954
+ else:
1955
+ s_C = C
1956
+
1957
+ face_hash = (F[:, 0] * 73856093 + F[:, 1] * 19349663 + F[:, 2] * 83492791) % (
1958
+ 2**31
1959
+ )
1960
+ if show_back is False:
1961
+ face_hash = face_hash[front]
1962
+
1963
+ # -------------------------
1964
+ # Process additional surfaces for depth-correct rendering
1965
+ # -------------------------
1966
+ all_T = [T]
1967
+ all_C = [s_C]
1968
+ all_Z = [Z]
1969
+ all_hash = [face_hash]
1970
+ all_alpha = [np.full(len(T), transparencies[k])]
1971
+ all_surf_idx = [np.zeros(len(T), dtype=int)] # 0 = main surface
1972
+
1973
+ if additional_surfaces is not None and len(additional_surfaces) > 0:
1974
+ n_add = len(additional_surfaces)
1975
+
1976
+ # Normalize parameters to lists
1977
+ add_overlays = (
1978
+ additional_overlays
1979
+ if additional_overlays is not None
1980
+ else [None] * n_add
1981
+ )
1982
+ add_colors = (
1983
+ additional_colors if additional_colors is not None else [None] * n_add
1984
+ )
1985
+ add_alphas = (
1986
+ additional_alphas if additional_alphas is not None else [1.0] * n_add
1987
+ )
1988
+ add_cmaps = (
1989
+ additional_cmaps if additional_cmaps is not None else [cmaps[k]] * n_add
1990
+ )
1991
+ add_vmins = (
1992
+ additional_vmins if additional_vmins is not None else [None] * n_add
1993
+ )
1994
+ add_vmaxs = (
1995
+ additional_vmaxs if additional_vmaxs is not None else [None] * n_add
1996
+ )
1997
+
1998
+ # Extend lists if needed
1999
+ if len(add_overlays) < n_add:
2000
+ add_overlays = list(add_overlays) + [None] * (n_add - len(add_overlays))
2001
+ if len(add_colors) < n_add:
2002
+ add_colors = list(add_colors) + [None] * (n_add - len(add_colors))
2003
+ if len(add_alphas) < n_add:
2004
+ add_alphas = list(add_alphas) + [1.0] * (n_add - len(add_alphas))
2005
+ if len(add_cmaps) < n_add:
2006
+ add_cmaps = list(add_cmaps) + [cmaps[k]] * (n_add - len(add_cmaps))
2007
+ if len(add_vmins) < n_add:
2008
+ add_vmins = list(add_vmins) + [None] * (n_add - len(add_vmins))
2009
+ if len(add_vmaxs) < n_add:
2010
+ add_vmaxs = list(add_vmaxs) + [None] * (n_add - len(add_vmaxs))
2011
+
2012
+ for surf_idx, add_surf in enumerate(additional_surfaces):
2013
+ # Resolve surface geometry
2014
+ add_verts, add_faces = _resolve_surface_geometry(
2015
+ add_surf,
2016
+ hemi=hemi,
2017
+ surface_suffix=surface_suffix,
2018
+ surface_density=surface_density,
2019
+ surface_kwargs=surface_kwargs,
2020
+ )
2021
+ add_verts = add_verts.astype(np.float32)
2022
+ add_F = add_faces.astype(int)
2023
+
2024
+ # Apply affine if provided
2025
+ if affine is not None and affine.shape == (4, 4):
2026
+ add_verts = (np.c_[add_verts, np.ones(len(add_verts))] @ affine.T)[
2027
+ :, :3
2028
+ ]
2029
+
2030
+ # Normalize for rendering (use same center/scale as main surface for consistency)
2031
+ add_proj_verts = (add_verts - _center) / _scale
2032
+
2033
+ # Compute shading intensity
2034
+ add_intensity, _ = _compute_shading_intensity(
2035
+ add_proj_verts,
2036
+ add_F,
2037
+ view_token,
2038
+ view,
2039
+ z_rotate,
2040
+ x_rotate,
2041
+ )
2042
+
2043
+ # Get overlay and color for this surface
2044
+ add_overlay = add_overlays[surf_idx]
2045
+ add_color = add_colors[surf_idx]
2046
+ add_alpha = add_alphas[surf_idx]
2047
+ add_cmap = add_cmaps[surf_idx]
2048
+ add_vmin = add_vmins[surf_idx]
2049
+ add_vmax = add_vmaxs[surf_idx]
2050
+
2051
+ # Compute face colors (similar to main surface logic)
2052
+ if add_overlay is None:
2053
+ add_C = np.ones((len(add_F), 4), dtype=float)
2054
+ if add_color is not None:
2055
+ import matplotlib.colors as mcolors
2056
+
2057
+ base_color = np.array(mcolors.to_rgba(add_color))
2058
+ add_C[:] = base_color
2059
+ else:
2060
+ add_C[:] = np.array([0.86, 0.86, 0.86, 1.0])
2061
+ else:
2062
+ # Per-face colour aggregation
2063
+ add_colours = np.mean(add_overlay[add_F], axis=1)
2064
+
2065
+ # Handle NaN values in overlay (from value_threshold)
2066
+ nan_mask = np.isnan(add_colours)
2067
+
2068
+ # Normalize to [0,1]
2069
+ if add_vmax is not None and add_vmin is not None:
2070
+ add_colours = (add_colours - add_vmin) / (add_vmax - add_vmin)
2071
+ add_colours = np.clip(add_colours, 0, 1)
2072
+ else:
2073
+ valid_colours = add_colours[~nan_mask]
2074
+ if len(valid_colours) > 0:
2075
+ cmin, cmax = np.nanmin(valid_colours), np.nanmax(
2076
+ valid_colours
2077
+ )
2078
+ if cmax > cmin:
2079
+ add_colours = (add_colours - cmin) / (cmax - cmin)
2080
+ else:
2081
+ add_colours = np.zeros_like(add_colours)
2082
+ else:
2083
+ add_colours = np.zeros_like(add_colours)
2084
+
2085
+ cmap_func = Colormap(add_cmap)
2086
+ add_C = cmap_func(np.clip(np.nan_to_num(add_colours, nan=0), 0, 1))
2087
+
2088
+ # Grey out NaN triangles (faces where any vertex has NaN)
2089
+ grey = np.array([0.86, 0.86, 0.86, 1.0])
2090
+ nan_faces = np.any(np.isnan(add_overlay[add_F]), axis=1)
2091
+ add_C[nan_faces] = grey
2092
+
2093
+ # Grey out faces below threshold
2094
+ if threshold is not None:
2095
+ is_below = np.any(add_overlay[add_F] <= threshold, axis=1)
2096
+ add_C[is_below] = grey
2097
+
2098
+ # Apply shading intensity
2099
+ add_C[:, 0:3] *= add_intensity[:, None]
2100
+
2101
+ # Project vertices
2102
+ add_V_all = np.c_[add_proj_verts, np.ones(len(add_proj_verts))] @ MVP.T
2103
+ add_V_all /= add_V_all[:, 3:4]
2104
+
2105
+ # Get triangles
2106
+ add_V = add_V_all[add_F]
2107
+ add_T = add_V[:, :, :2]
2108
+
2109
+ # Transform to world coords if needed
2110
+ if world_coords:
2111
+ add_T[:, :, 0] = wxmin + (add_T[:, :, 0] - pxmin) * sx
2112
+ add_T[:, :, 1] = wymin + (add_T[:, :, 1] - pymin) * sy
2113
+
2114
+ # Flip x-axis around x=0 to match slice neurological convention
2115
+ if flip_x:
2116
+ add_T[:, :, 0] = -add_T[:, :, 0]
2117
+
2118
+ # Depth and culling
2119
+ add_Z = -add_V[:, :, 2].mean(axis=1)
2120
+ add_front, _ = frontback(add_T)
2121
+ if show_back is False:
2122
+ add_T = add_T[add_front]
2123
+ add_C = add_C[add_front]
2124
+ add_Z = add_Z[add_front]
2125
+
2126
+ add_face_hash = (
2127
+ add_F[:, 0] * 73856093
2128
+ + add_F[:, 1] * 19349663
2129
+ + add_F[:, 2] * 83492791
2130
+ ) % (2**31)
2131
+ if show_back is False:
2132
+ add_face_hash = add_face_hash[add_front]
2133
+
2134
+ # Append to combined lists
2135
+ all_T.append(add_T)
2136
+ all_C.append(add_C)
2137
+ all_Z.append(add_Z)
2138
+ all_hash.append(add_face_hash)
2139
+ all_alpha.append(np.full(len(add_T), add_alpha))
2140
+ all_surf_idx.append(np.full(len(add_T), surf_idx + 1, dtype=int))
2141
+
2142
+ # Combine all triangles and sort by depth
2143
+ combined_T = np.concatenate(all_T, axis=0)
2144
+ combined_C = np.concatenate(all_C, axis=0)
2145
+ combined_Z = np.concatenate(all_Z, axis=0)
2146
+ combined_hash = np.concatenate(all_hash, axis=0)
2147
+ combined_alpha = np.concatenate(all_alpha, axis=0)
2148
+
2149
+ I = np.lexsort((combined_hash, combined_Z))
2150
+ combined_T = combined_T[I]
2151
+ combined_C = combined_C[I]
2152
+ combined_alpha = combined_alpha[I]
2153
+
2154
+ # Apply per-face alpha
2155
+ combined_C[:, 3] *= combined_alpha
2156
+
2157
+ # Mark fully transparent faces as NaN so matplotlib skips them.
2158
+ # Only affect faces where alpha is exactly 0 (intentionally hidden).
2159
+ _fully_transparent = combined_C[:, 3] == 0
2160
+ combined_C[_fully_transparent] = np.nan
2161
+ combined_surf_idx = np.concatenate(all_surf_idx, axis=0)[I]
2162
+
2163
+ # Per-face styling: faces_kwargs applies only to main surface by default.
2164
+ # If faces_kwargs is a list, element 0 → main, 1 → additional[0], etc.
2165
+ import matplotlib.colors as _mcolors
2166
+
2167
+ _fc = combined_C.copy()
2168
+ _ec = np.zeros((len(combined_T), 4)) # no edges by default
2169
+ _lw = np.zeros(len(combined_T))
2170
+ _extra_kw = dict(antialiased=False)
2171
+
2172
+ if faces_kwargs is not None:
2173
+ if isinstance(faces_kwargs, list):
2174
+ fk_list = faces_kwargs
2175
+ else:
2176
+ fk_list = [faces_kwargs] # single dict → apply to main only
2177
+
2178
+ for fk_idx, fk_dict in enumerate(fk_list):
2179
+ if fk_dict is None:
2180
+ continue
2181
+ mask = combined_surf_idx == fk_idx
2182
+ if not mask.any():
2183
+ continue
2184
+ # Process edgecolor first so "inherit"/"face" can grab
2185
+ # the original face color before facecolor is cleared.
2186
+ ordered_keys = sorted(fk_dict.keys(), key=lambda k: (k != "edgecolor", k))
2187
+ for key in ordered_keys:
2188
+ val = fk_dict[key]
2189
+ if key == "facecolor":
2190
+ if val == "none" or val is None:
2191
+ _fc[mask] = [0, 0, 0, 0]
2192
+ else:
2193
+ _fc[mask] = np.array(_mcolors.to_rgba(val))
2194
+ elif key == "edgecolor":
2195
+ if val in ("inherit", "face"):
2196
+ # Copy the current face color as edge color
2197
+ _ec[mask] = _fc[mask]
2198
+ elif isinstance(val, (tuple, list)) and len(val) == 4:
2199
+ _ec[mask] = np.array(val)
2200
+ else:
2201
+ _ec[mask] = np.array(_mcolors.to_rgba(val))
2202
+ elif key == "linewidth":
2203
+ _lw[mask] = val
2204
+ else:
2205
+ _extra_kw[key] = val
2206
+
2207
+ collection = PolyCollection(
2208
+ combined_T,
2209
+ closed=True,
2210
+ **_extra_kw,
2211
+ **kwargs,
2212
+ )
2213
+ collection.set_facecolor(_fc)
2214
+ collection.set_edgecolor(_ec)
2215
+ collection.set_linewidths(_lw)
2216
+ if label and label is not False:
2217
+ import matplotlib.patches as mpatches
2218
+ _proxy = mpatches.Rectangle(
2219
+ (0, 0), 0, 0, facecolor=_legend_color, label=label
2220
+ )
2221
+ ax.add_patch(_proxy)
2222
+
2223
+ # Legend proxies for dict-unpacked additional surfaces
2224
+ if _additional_labels is not None:
2225
+ import matplotlib.patches as mpatches
2226
+ import matplotlib.colors as _mcolors_leg
2227
+ for _al_i, _al_lbl in enumerate(_additional_labels):
2228
+ _al_col = (
2229
+ additional_colors[_al_i]
2230
+ if additional_colors and _al_i < len(additional_colors)
2231
+ else None
2232
+ )
2233
+ if _al_col is None:
2234
+ _al_col = (0.86, 0.86, 0.86, 1.0)
2235
+ _proxy = mpatches.Rectangle(
2236
+ (0, 0), 0, 0,
2237
+ facecolor=_mcolors_leg.to_rgba(_al_col),
2238
+ label=_al_lbl,
2239
+ )
2240
+ ax.add_patch(_proxy)
2241
+
2242
+ for path in collection.get_paths():
2243
+ path.codes[-1] = 0
2244
+
2245
+ ax.add_collection(collection)
2246
+
2247
+ # Optional vertex scatter overlay
2248
+ if vertices_kwargs is not None:
2249
+ _vkw = {
2250
+ "s": 0.1,
2251
+ "color": (0.6, 0.6, 0.6),
2252
+ "alpha": 0.3,
2253
+ "edgecolors": "none",
2254
+ "rasterized": True,
2255
+ "zorder": collection.get_zorder() + 0.5,
2256
+ }
2257
+ _vkw.update(vertices_kwargs)
2258
+ # Decimate if requested
2259
+ _v_decimate = _vkw.pop("decimate", None)
2260
+ _v_dec_method = _vkw.pop("decimate_method", "uniform")
2261
+ v_pts = V_all[:, :2].copy()
2262
+ v_z = V_all[:, 2].copy()
2263
+
2264
+ # Transform to world coords (same as triangle coordinates)
2265
+ if world_coords:
2266
+ v_pts[:, 0] = wxmin + (v_pts[:, 0] - pxmin) * sx
2267
+ v_pts[:, 1] = wymin + (v_pts[:, 1] - pymin) * sy
2268
+ if flip_x:
2269
+ v_pts[:, 0] = -v_pts[:, 0]
2270
+
2271
+ if _v_decimate is not None and _v_decimate < 1.0:
2272
+ step = max(1, int(1 / _v_decimate))
2273
+ keep = np.arange(0, len(v_pts), step)
2274
+ v_pts = v_pts[keep]
2275
+ v_z = v_z[keep]
2276
+ # Only show front-facing vertices (same depth cull as faces)
2277
+ if show_back is False:
2278
+ front_verts = np.unique(F[front].ravel()) if 'front' in dir() else np.arange(len(v_pts))
2279
+ front_verts = front_verts[front_verts < len(v_pts)]
2280
+ v_pts = v_pts[front_verts]
2281
+ v_z = v_z[front_verts]
2282
+ # Depth-based alpha
2283
+ _v_alpha = _vkw.pop("alpha", 0.3)
2284
+ z_range = v_z.max() - v_z.min()
2285
+ z_norm = (v_z - v_z.min()) / (z_range + 1e-9)
2286
+ per_point_alpha = _v_alpha * (0.3 + 0.7 * z_norm)
2287
+ order = np.argsort(-v_z) # back to front
2288
+ ax.scatter(v_pts[order, 0], v_pts[order, 1], alpha=per_point_alpha[order], **_vkw)
2289
+
2290
+ # Axis limits - expand to include all surfaces
2291
+ if world_coords:
2292
+ # Start with main surface limits
2293
+ all_wxmin, all_wxmax = wxmin, wxmax
2294
+ all_wymin, all_wymax = wymin, wymax
2295
+
2296
+ # Expand for additional surfaces if present
2297
+ if additional_surfaces is not None and len(additional_surfaces) > 0:
2298
+ for add_surf in additional_surfaces:
2299
+ add_verts, _ = _resolve_surface_geometry(
2300
+ add_surf,
2301
+ hemi=hemi,
2302
+ surface_suffix=surface_suffix,
2303
+ surface_density=surface_density,
2304
+ surface_kwargs=surface_kwargs,
2305
+ )
2306
+ if affine is not None and affine.shape == (4, 4):
2307
+ add_verts = (
2308
+ np.c_[add_verts, np.ones(len(add_verts))] @ affine.T
2309
+ )[:, :3]
2310
+ add_V_world = np.c_[add_verts, np.ones(len(add_verts))] @ ROT.T
2311
+ all_wxmin = min(all_wxmin, add_V_world[:, 0].min())
2312
+ all_wxmax = max(all_wxmax, add_V_world[:, 0].max())
2313
+ all_wymin = min(all_wymin, add_V_world[:, 1].min())
2314
+ all_wymax = max(all_wymax, add_V_world[:, 1].max())
2315
+
2316
+ # Avoid singular transform when min == max
2317
+ if all_wxmin == all_wxmax:
2318
+ all_wxmin -= 0.5
2319
+ all_wxmax += 0.5
2320
+ if all_wymin == all_wymax:
2321
+ all_wymin -= 0.5
2322
+ all_wymax += 0.5
2323
+ ax.set_xlim([all_wxmin, all_wxmax])
2324
+ ax.set_ylim([all_wymin, all_wymax])
2325
+ else:
2326
+ x_min, x_max = V_all[:, 0].min(), V_all[:, 0].max()
2327
+ y_min, y_max = V_all[:, 1].min(), V_all[:, 1].max()
2328
+ if x_min == x_max:
2329
+ x_min -= 0.5
2330
+ x_max += 0.5
2331
+ if y_min == y_max:
2332
+ y_min -= 0.5
2333
+ y_max += 0.5
2334
+ ax.set_xlim([x_min, x_max])
2335
+ ax.set_ylim([y_min, y_max])
2336
+
2337
+ ax.set_aspect("equal")
2338
+
2339
+ # Draw arrows (optional)
2340
+ if arrows is not None and arrow_subset is not None:
2341
+ front_arrows = F[front].ravel() if (show_back is False) else F.ravel()
2342
+ center = np.array([0, 0, 0, 1]) @ MVP.T
2343
+ center = center / center[3]
2344
+ for arrow_index, i in enumerate(arrow_subset):
2345
+ if i in front_arrows and A_base[i, 2] < center[2] + 0.01:
2346
+ arrow_colour = (
2347
+ arrow_colours[arrow_index] if arrow_colours is not None else "k"
2348
+ )
2349
+ if len(A_dir) == len(A_base):
2350
+ direction = A_dir[i]
2351
+ elif len(A_dir) == len(arrow_subset):
2352
+ direction = A_dir[arrow_index]
2353
+ else:
2354
+ direction = A_dir[i % len(A_dir)]
2355
+ half = direction * 0.5
2356
+ ax.arrow(
2357
+ A_base[i, 0] - half[0],
2358
+ A_base[i, 1] - half[1],
2359
+ direction[0],
2360
+ direction[1],
2361
+ head_width=arrow_head,
2362
+ width=arrow_width,
2363
+ color=arrow_colour,
2364
+ )
2365
+
2366
+ if return_fig:
2367
+ plt.close()
2368
+ return fig
2369
+
2370
+ return collection