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/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
|