clabtoolkit 0.3.3__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.
- clabtoolkit/__init__.py +5 -0
- clabtoolkit/bidstools.py +2260 -0
- clabtoolkit/config/bids.json +147 -0
- clabtoolkit/config/config.json +58 -0
- clabtoolkit/config/lobes.json +112 -0
- clabtoolkit/config/stats_mapping.json +140 -0
- clabtoolkit/config/viz_views.json +268 -0
- clabtoolkit/connectivitytools.py +0 -0
- clabtoolkit/dicomtools.py +941 -0
- clabtoolkit/dwitools.py +1999 -0
- clabtoolkit/freesurfertools.py +5378 -0
- clabtoolkit/imagetools.py +2734 -0
- clabtoolkit/misctools.py +5204 -0
- clabtoolkit/morphometrytools.py +2970 -0
- clabtoolkit/networktools.py +351 -0
- clabtoolkit/parcellationtools.py +3239 -0
- clabtoolkit/pipelinetools.py +1187 -0
- clabtoolkit/plottools.py +682 -0
- clabtoolkit/qcqatools.py +1305 -0
- clabtoolkit/segmentationtools.py +356 -0
- clabtoolkit/surfacetools.py +2785 -0
- clabtoolkit/visualizationtools.py +5575 -0
- clabtoolkit-0.3.3.dist-info/METADATA +212 -0
- clabtoolkit-0.3.3.dist-info/RECORD +28 -0
- clabtoolkit-0.3.3.dist-info/WHEEL +5 -0
- clabtoolkit-0.3.3.dist-info/licenses/AUTHORS.rst +13 -0
- clabtoolkit-0.3.3.dist-info/licenses/LICENSE +16 -0
- clabtoolkit-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2785 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import numpy as np
|
|
3
|
+
import nibabel as nib
|
|
4
|
+
from typing import Union, List, Dict, Optional, Tuple
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import pyvista as pv
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import copy
|
|
9
|
+
|
|
10
|
+
# Importing local modules
|
|
11
|
+
from . import freesurfertools as cltfree
|
|
12
|
+
from . import misctools as cltmisc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
####################################################################################################
|
|
16
|
+
####################################################################################################
|
|
17
|
+
############ ############
|
|
18
|
+
############ ############
|
|
19
|
+
############ Section 1: Class and methods work with meshes ############
|
|
20
|
+
############ ############
|
|
21
|
+
############ ############
|
|
22
|
+
####################################################################################################
|
|
23
|
+
####################################################################################################
|
|
24
|
+
class Surface:
|
|
25
|
+
"""
|
|
26
|
+
Comprehensive class for loading and visualizing brain surface data.
|
|
27
|
+
|
|
28
|
+
Provides interface for working with brain surface geometries including loading
|
|
29
|
+
from files or arrays, managing scalar maps and parcellations, and creating
|
|
30
|
+
visualizations using PyVista. Supports FreeSurfer and other surface formats.
|
|
31
|
+
|
|
32
|
+
Attributes
|
|
33
|
+
----------
|
|
34
|
+
surf : str or None
|
|
35
|
+
Path to surface file if loaded from file.
|
|
36
|
+
|
|
37
|
+
mesh : pv.PolyData
|
|
38
|
+
PyVista mesh object containing surface geometry and data.
|
|
39
|
+
|
|
40
|
+
hemi : str
|
|
41
|
+
Hemisphere designation ('lh', 'rh', or 'unknown').
|
|
42
|
+
|
|
43
|
+
colortables : dict
|
|
44
|
+
Dictionary storing color table information for parcellations.
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> # Load from FreeSurfer surface file
|
|
49
|
+
>>> surface = Surface('lh.pial')
|
|
50
|
+
>>>
|
|
51
|
+
>>> # Create from vertex/face arrays
|
|
52
|
+
>>> surface = Surface(vertices=verts, faces=faces, hemi='lh')
|
|
53
|
+
>>>
|
|
54
|
+
>>> # Load scalar data and parcellations
|
|
55
|
+
>>> surface.load_scalar_map('thickness.mgh', 'thickness')
|
|
56
|
+
>>> surface.load_annotation('lh.aparc.annot', 'aparc')
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
##############################################################################################
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
surface_file: Union[str, Path] = None,
|
|
63
|
+
vertices: np.ndarray = None,
|
|
64
|
+
faces: np.ndarray = None,
|
|
65
|
+
color: Union[str, np.ndarray] = "#f0f0f0",
|
|
66
|
+
alpha: float = 1.0,
|
|
67
|
+
hemi: str = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Initialize Surface object from file, arrays, or create empty instance.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
surface_file : str or Path, optional
|
|
75
|
+
Path to surface file (FreeSurfer .pial, .white, .inflated). Default is None.
|
|
76
|
+
|
|
77
|
+
vertices : np.ndarray, optional
|
|
78
|
+
Vertex coordinates array with shape (n_vertices, 3). Default is None.
|
|
79
|
+
|
|
80
|
+
faces : np.ndarray, optional
|
|
81
|
+
Face connectivity array with shape (n_faces, 3). Default is None.
|
|
82
|
+
|
|
83
|
+
color : str or np.ndarray, optional
|
|
84
|
+
Color for the surface mesh. Can be a hex color string (e.g., '#f0f0f0')
|
|
85
|
+
or an RGB array in [0, 1] or [0, 255] range. Default is '#f0f0f0'.
|
|
86
|
+
|
|
87
|
+
alpha : float, optional
|
|
88
|
+
Alpha transparency value in [0, 1] for the surface color. Default is 1.0 (opaque).
|
|
89
|
+
|
|
90
|
+
hemi : str, optional
|
|
91
|
+
Hemisphere designation ('lh' or 'rh'). Auto-detected from filename
|
|
92
|
+
if None. Default is None.
|
|
93
|
+
|
|
94
|
+
Raises
|
|
95
|
+
------
|
|
96
|
+
ValueError
|
|
97
|
+
If both surface_file and vertices/faces are provided, or if only
|
|
98
|
+
one of vertices/faces is provided.
|
|
99
|
+
FileNotFoundError
|
|
100
|
+
If surface file doesn't exist.
|
|
101
|
+
|
|
102
|
+
Examples
|
|
103
|
+
--------
|
|
104
|
+
>>> # Load from file with auto-detection
|
|
105
|
+
>>> surface = Surface('lh.pial')
|
|
106
|
+
>>>
|
|
107
|
+
>>> # Create from arrays
|
|
108
|
+
>>> vertices = np.random.rand(100, 3)
|
|
109
|
+
>>> faces = np.array([[0, 1, 2], [1, 2, 3]])
|
|
110
|
+
>>> surface = Surface(vertices=vertices, faces=faces, hemi='lh')
|
|
111
|
+
>>>
|
|
112
|
+
>>> # Create empty instance
|
|
113
|
+
>>> surface = Surface()
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# Initialize attributes to None (empty instance)
|
|
117
|
+
self.surf = None
|
|
118
|
+
self.mesh = None
|
|
119
|
+
self.hemi = None
|
|
120
|
+
self.colortables: Dict[str, Dict] = {}
|
|
121
|
+
|
|
122
|
+
# Create the defalt colortable for the surface
|
|
123
|
+
|
|
124
|
+
# Set the colortable for the surface
|
|
125
|
+
# Validate alpha value
|
|
126
|
+
if isinstance(alpha, int):
|
|
127
|
+
alpha = float(alpha)
|
|
128
|
+
|
|
129
|
+
# If the alpha is not in the range [0, 1], raise an error
|
|
130
|
+
if not (0 <= alpha <= 1):
|
|
131
|
+
raise ValueError(f"Alpha value must be in the range [0, 1], got {alpha}")
|
|
132
|
+
|
|
133
|
+
# Handle color input
|
|
134
|
+
color = cltmisc.harmonize_colors(color, output_format="rgb") / 255
|
|
135
|
+
|
|
136
|
+
tmp_ctable = cltmisc.colors_to_table(colors=color, alpha_values=alpha)
|
|
137
|
+
tmp_ctable[:, :3] = tmp_ctable[:, :3] / 255 # Ensure colors are between 0 and 1
|
|
138
|
+
|
|
139
|
+
# Store parcellation information in organized structure
|
|
140
|
+
self.colortables["surface"] = {
|
|
141
|
+
"struct_names": ["surface"],
|
|
142
|
+
"color_table": tmp_ctable,
|
|
143
|
+
"lookup_table": None, # Will be populated by _create_parcellation_colortable if needed
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Validate input parameters
|
|
147
|
+
if surface_file is not None and (vertices is not None or faces is not None):
|
|
148
|
+
raise ValueError("Cannot specify both surface_file and vertices/faces")
|
|
149
|
+
|
|
150
|
+
if vertices is not None and faces is None:
|
|
151
|
+
raise ValueError("If vertices are provided, faces must also be provided")
|
|
152
|
+
|
|
153
|
+
if faces is not None and vertices is None:
|
|
154
|
+
raise ValueError("If faces are provided, vertices must also be provided")
|
|
155
|
+
|
|
156
|
+
# Load data if provided
|
|
157
|
+
if surface_file is not None:
|
|
158
|
+
if isinstance(surface_file, Path):
|
|
159
|
+
surface_file = str(surface_file)
|
|
160
|
+
|
|
161
|
+
if isinstance(surface_file, str):
|
|
162
|
+
if not os.path.isfile(surface_file):
|
|
163
|
+
raise FileNotFoundError(f"Surface file not found: {surface_file}")
|
|
164
|
+
|
|
165
|
+
self.surf = surface_file
|
|
166
|
+
self.load_from_file(surface_file, color, alpha, hemi)
|
|
167
|
+
|
|
168
|
+
elif isinstance(surface_file, pv.PolyData):
|
|
169
|
+
self.load_from_mesh(surface_file, color, alpha, hemi)
|
|
170
|
+
|
|
171
|
+
elif vertices is not None and faces is not None:
|
|
172
|
+
self.load_from_arrays(vertices, faces, color, alpha, hemi=hemi)
|
|
173
|
+
|
|
174
|
+
################################################################################################
|
|
175
|
+
def load_from_file(
|
|
176
|
+
self,
|
|
177
|
+
surface_file: Union[str, Path],
|
|
178
|
+
color: Union[str, np.ndarray] = "#f0f0f0",
|
|
179
|
+
alpha: np.float32 = 1.0,
|
|
180
|
+
hemi: str = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Load surface geometry from FreeSurfer or compatible surface file.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
surface_file : str or Path
|
|
188
|
+
Path to surface file (e.g., FreeSurfer .pial, .white, .inflated).
|
|
189
|
+
|
|
190
|
+
color : str or np.ndarray, optional
|
|
191
|
+
Color for the surface mesh. Can be a hex color string (e.g., '#f0f0f0')
|
|
192
|
+
or an RGB array in [0, 1] or [0, 255] range. Default is '#f0f0f0'.
|
|
193
|
+
|
|
194
|
+
alpha : float, optional
|
|
195
|
+
Alpha transparency value in [0, 1] for the surface color. Default is 1.0 (opaque).
|
|
196
|
+
|
|
197
|
+
hemi : str, optional
|
|
198
|
+
Hemisphere designation ('lh' or 'rh'). Auto-detected from filename
|
|
199
|
+
if None. Default is None.
|
|
200
|
+
|
|
201
|
+
Raises
|
|
202
|
+
------
|
|
203
|
+
FileNotFoundError
|
|
204
|
+
If surface file cannot be found.
|
|
205
|
+
ValueError
|
|
206
|
+
If surface file format is unsupported or corrupted.
|
|
207
|
+
|
|
208
|
+
Notes
|
|
209
|
+
-----
|
|
210
|
+
- Automatically detects hemisphere from filename if not provided.
|
|
211
|
+
- Converts color string to RGB array if provided as hex.
|
|
212
|
+
- Adds alpha channel to color for RGBA representation.
|
|
213
|
+
- Creates default parcellation data for visualization.
|
|
214
|
+
- Uses nibabel to read FreeSurfer geometry files.
|
|
215
|
+
- Handles both left ('lh') and right ('rh') hemisphere surfaces.
|
|
216
|
+
- If color is not provided, defaults to light gray ('#f0f0f0').
|
|
217
|
+
|
|
218
|
+
Examples
|
|
219
|
+
--------
|
|
220
|
+
>>> surface = Surface()
|
|
221
|
+
>>> surface.load_from_file('lh.pial')
|
|
222
|
+
>>> print(f"Loaded {surface.mesh.n_points} vertices")
|
|
223
|
+
>>>
|
|
224
|
+
>>> # Explicit hemisphere specification
|
|
225
|
+
>>> surface.load_from_file('brain_surface.surf', hemi='rh')
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
# Check if the surface file exists
|
|
229
|
+
if isinstance(surface_file, Path):
|
|
230
|
+
surface_file = str(surface_file)
|
|
231
|
+
|
|
232
|
+
if not os.path.isfile(surface_file):
|
|
233
|
+
raise FileNotFoundError(f"Surface file not found: {surface_file}")
|
|
234
|
+
|
|
235
|
+
# Store the surface file path
|
|
236
|
+
self.surf = surface_file
|
|
237
|
+
|
|
238
|
+
# Validate alpha value
|
|
239
|
+
if isinstance(alpha, int):
|
|
240
|
+
alpha = float(alpha)
|
|
241
|
+
|
|
242
|
+
# If the alpha is not in the range [0, 1], raise an error
|
|
243
|
+
if not (0 <= alpha <= 1):
|
|
244
|
+
raise ValueError(f"Alpha value must be in the range [0, 1], got {alpha}")
|
|
245
|
+
|
|
246
|
+
# Handle color input
|
|
247
|
+
color = cltmisc.harmonize_colors(color, output_format="rgb") / 255
|
|
248
|
+
|
|
249
|
+
# Load the surface geometry
|
|
250
|
+
try:
|
|
251
|
+
vertices, faces = nib.freesurfer.read_geometry(self.surf)
|
|
252
|
+
|
|
253
|
+
# Add column with 3's to faces array for PyVista
|
|
254
|
+
faces = np.c_[np.full(len(faces), 3), faces]
|
|
255
|
+
|
|
256
|
+
mesh = pv.PolyData(vertices, faces)
|
|
257
|
+
|
|
258
|
+
# Add default surface colors if not present
|
|
259
|
+
|
|
260
|
+
# Adding the mesh
|
|
261
|
+
self.mesh = mesh
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
raise ValueError(f"Failed to load surface file '{self.surf}': {e}")
|
|
265
|
+
|
|
266
|
+
# Hemisphere detection from filename
|
|
267
|
+
if hemi is not None:
|
|
268
|
+
self.hemi = hemi
|
|
269
|
+
else:
|
|
270
|
+
self.hemi = cltfree.detect_hemi(self.surf)
|
|
271
|
+
|
|
272
|
+
# Fallback hemisphere detection from BIDS organization
|
|
273
|
+
surf_name = os.path.basename(self.surf)
|
|
274
|
+
detected_hemi = cltfree.detect_hemi(surf_name)
|
|
275
|
+
|
|
276
|
+
if detected_hemi is None:
|
|
277
|
+
self.hemi = "lh" # Default to left hemisphere
|
|
278
|
+
|
|
279
|
+
# Create default parcellation data
|
|
280
|
+
self._create_default_parcellation(
|
|
281
|
+
color=color,
|
|
282
|
+
alpha=alpha,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
##############################################################################################
|
|
286
|
+
def load_from_arrays(
|
|
287
|
+
self,
|
|
288
|
+
vertices: np.ndarray,
|
|
289
|
+
faces: np.ndarray,
|
|
290
|
+
color: Union[str, np.ndarray] = "#f0f0f0",
|
|
291
|
+
alpha: float = 1.0,
|
|
292
|
+
hemi: str = None,
|
|
293
|
+
surface_file: str = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""
|
|
296
|
+
Load surface geometry from vertex and face arrays.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
vertices : np.ndarray
|
|
301
|
+
Vertex coordinates with shape (n_vertices, 3).
|
|
302
|
+
|
|
303
|
+
faces : np.ndarray
|
|
304
|
+
Face connectivity with shape (n_faces, 3).
|
|
305
|
+
|
|
306
|
+
color : str or np.ndarray, optional
|
|
307
|
+
Color for the surface mesh. Can be a hex color string (e.g., '#f0f0f0')
|
|
308
|
+
or an RGB array in [0, 1] or [0, 255] range. Default is '#f0f0f0'.
|
|
309
|
+
|
|
310
|
+
alpha : float, optional
|
|
311
|
+
Alpha transparency value in [0, 1] for the surface color. Default is 1.0 (opaque).
|
|
312
|
+
|
|
313
|
+
hemi : str, optional
|
|
314
|
+
Hemisphere designation ('lh' or 'rh'). Defaults to 'lh'.
|
|
315
|
+
|
|
316
|
+
surface_file : str, optional
|
|
317
|
+
Associated surface file path for metadata. Default is None.
|
|
318
|
+
|
|
319
|
+
Raises
|
|
320
|
+
------
|
|
321
|
+
ValueError
|
|
322
|
+
If vertices or faces arrays have incorrect shapes.
|
|
323
|
+
|
|
324
|
+
Examples
|
|
325
|
+
--------
|
|
326
|
+
>>> # Basic triangle mesh
|
|
327
|
+
>>> vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
|
|
328
|
+
>>> faces = np.array([[0, 1, 2]])
|
|
329
|
+
>>> surface = Surface()
|
|
330
|
+
>>> surface.load_from_arrays(vertices, faces, hemi='lh')
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
# Validate alpha value
|
|
334
|
+
if isinstance(alpha, int):
|
|
335
|
+
alpha = float(alpha)
|
|
336
|
+
|
|
337
|
+
# If the alpha is not in the range [0, 1], raise an error
|
|
338
|
+
if not (0 <= alpha <= 1):
|
|
339
|
+
raise ValueError(f"Alpha value must be in the range [0, 1], got {alpha}")
|
|
340
|
+
|
|
341
|
+
# Handle color input
|
|
342
|
+
color = cltmisc.harmonize_colors(color, output_format="rgb") / 255
|
|
343
|
+
|
|
344
|
+
self.surf = surface_file
|
|
345
|
+
self.mesh = self.create_mesh_from_arrays(vertices, faces)
|
|
346
|
+
self.hemi = hemi if hemi is not None else "lh" # Default to left hemisphere
|
|
347
|
+
|
|
348
|
+
# Create default parcellation data
|
|
349
|
+
self._create_default_parcellation(
|
|
350
|
+
color=color,
|
|
351
|
+
alpha=alpha,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
##############################################################################################
|
|
355
|
+
def load_from_mesh(
|
|
356
|
+
self,
|
|
357
|
+
mesh: pv.PolyData,
|
|
358
|
+
color: Union[str, np.ndarray] = "#f0f0f0",
|
|
359
|
+
alpha: float = 1.0,
|
|
360
|
+
hemi: str = None,
|
|
361
|
+
) -> None:
|
|
362
|
+
"""
|
|
363
|
+
Load surface geometry from existing PyVista mesh object.
|
|
364
|
+
|
|
365
|
+
Parameters
|
|
366
|
+
----------
|
|
367
|
+
mesh : pv.PolyData
|
|
368
|
+
PyVista mesh object containing surface geometry.
|
|
369
|
+
|
|
370
|
+
color : str or np.ndarray, optional
|
|
371
|
+
Color for the surface mesh. Can be a hex color string (e.g., '#f0f0f0')
|
|
372
|
+
or an RGB array in [0, 1] or [0, 255] range. Default is '#f0f0f0'.
|
|
373
|
+
|
|
374
|
+
alpha : float, optional
|
|
375
|
+
Alpha transparency value in [0, 1] for the surface color. Default is 1.0 (opaque).
|
|
376
|
+
|
|
377
|
+
hemi : str, optional
|
|
378
|
+
Hemisphere designation ('lh' or 'rh'). Defaults to 'lh'.
|
|
379
|
+
|
|
380
|
+
Notes
|
|
381
|
+
-----
|
|
382
|
+
Creates a deep copy of the input mesh to avoid modifying the original.
|
|
383
|
+
Adds default surface colors if not present in the mesh.
|
|
384
|
+
|
|
385
|
+
Examples
|
|
386
|
+
--------
|
|
387
|
+
>>> # From existing PyVista mesh
|
|
388
|
+
>>> existing_mesh = pv.PolyData(vertices, faces)
|
|
389
|
+
>>> surface = Surface()
|
|
390
|
+
>>> surface.load_from_mesh(existing_mesh, hemi='rh')
|
|
391
|
+
>>>
|
|
392
|
+
>>> # From procedural mesh
|
|
393
|
+
>>> sphere = pv.Sphere(radius=50)
|
|
394
|
+
>>> surface.load_from_mesh(sphere, hemi='lh')
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
self.surf = None
|
|
398
|
+
self.mesh = copy.deepcopy(mesh) # Make a copy to avoid modifying the original
|
|
399
|
+
self.hemi = hemi if hemi is not None else "lh" # Default to left hemisphere
|
|
400
|
+
|
|
401
|
+
# Validate alpha value
|
|
402
|
+
if isinstance(alpha, int):
|
|
403
|
+
alpha = float(alpha)
|
|
404
|
+
|
|
405
|
+
# If the alpha is not in the range [0, 1], raise an error
|
|
406
|
+
if not (0 <= alpha <= 1):
|
|
407
|
+
raise ValueError(f"Alpha value must be in the range [0, 1], got {alpha}")
|
|
408
|
+
|
|
409
|
+
# Handle color input
|
|
410
|
+
color = cltmisc.harmonize_colors(color, output_format="rgb") / 255
|
|
411
|
+
|
|
412
|
+
# Ensure mesh has default surface colors if not present
|
|
413
|
+
self._create_default_parcellation(color=color, alpha=alpha)
|
|
414
|
+
|
|
415
|
+
##############################################################################################
|
|
416
|
+
def is_loaded(self) -> bool:
|
|
417
|
+
"""
|
|
418
|
+
Check whether surface data has been loaded.
|
|
419
|
+
|
|
420
|
+
Returns
|
|
421
|
+
-------
|
|
422
|
+
bool
|
|
423
|
+
True if surface data is loaded, False otherwise.
|
|
424
|
+
|
|
425
|
+
Examples
|
|
426
|
+
--------
|
|
427
|
+
>>> surface = Surface()
|
|
428
|
+
>>> print(surface.is_loaded()) # False
|
|
429
|
+
>>> surface.load_from_file('lh.pial')
|
|
430
|
+
>>> print(surface.is_loaded()) # True
|
|
431
|
+
"""
|
|
432
|
+
return self.mesh is not None
|
|
433
|
+
|
|
434
|
+
##############################################################################################
|
|
435
|
+
def _create_default_parcellation(
|
|
436
|
+
self, color: Union[str, np.ndarray] = "#f0f0f0", alpha: np.ndarray = 1.0
|
|
437
|
+
) -> None:
|
|
438
|
+
"""
|
|
439
|
+
Create default parcellation data for surface visualization.
|
|
440
|
+
|
|
441
|
+
Internal method that sets up basic parcellation with uniform surface
|
|
442
|
+
colors for initial visualization before loading specific annotations.
|
|
443
|
+
|
|
444
|
+
Parameters
|
|
445
|
+
----------
|
|
446
|
+
|
|
447
|
+
color : str or np.ndarray
|
|
448
|
+
Color for the surface mesh. Can be a hex color string (e.g., '#f0f0f0')
|
|
449
|
+
or an RGB array in [0, 1] or [0, 255] range.
|
|
450
|
+
|
|
451
|
+
alpha : float
|
|
452
|
+
Alpha transparency value in [0, 1] for the surface color. Default is 1.0 (opaque).
|
|
453
|
+
|
|
454
|
+
Notes
|
|
455
|
+
-----
|
|
456
|
+
Creates a single-region parcellation with default gray color values
|
|
457
|
+
assigned to all vertices.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
tmp_ctable = cltmisc.colors_to_table(colors=color, alpha_values=alpha)
|
|
461
|
+
tmp_ctable[:, :3] = tmp_ctable[:, :3] / 255 # Ensure colors are between 0 and 1
|
|
462
|
+
|
|
463
|
+
self._store_parcellation_data(
|
|
464
|
+
np.ones((self.mesh.n_points,), dtype=np.uint32) * int(tmp_ctable[0, 4]),
|
|
465
|
+
tmp_ctable,
|
|
466
|
+
["surface"],
|
|
467
|
+
"surface",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
##############################################################################################
|
|
471
|
+
def create_mesh_from_arrays(
|
|
472
|
+
self, vertices: np.ndarray, faces: np.ndarray
|
|
473
|
+
) -> pv.PolyData:
|
|
474
|
+
"""
|
|
475
|
+
Create PyVista mesh object from vertex and face arrays.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
vertices : np.ndarray
|
|
480
|
+
Vertex coordinates with shape (n_vertices, 3).
|
|
481
|
+
|
|
482
|
+
faces : np.ndarray
|
|
483
|
+
Face connectivity with shape (n_faces, 3).
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
pv.PolyData
|
|
488
|
+
PyVista mesh object with vertices, faces, and default surface colors.
|
|
489
|
+
|
|
490
|
+
Raises
|
|
491
|
+
------
|
|
492
|
+
ValueError
|
|
493
|
+
If arrays have incorrect shapes or face indices are invalid.
|
|
494
|
+
|
|
495
|
+
Notes
|
|
496
|
+
-----
|
|
497
|
+
Validates input arrays and creates properly formatted PyVista mesh
|
|
498
|
+
with default surface colors. Adds normals to point data if provided.
|
|
499
|
+
|
|
500
|
+
Examples
|
|
501
|
+
--------
|
|
502
|
+
>>> surface = Surface()
|
|
503
|
+
>>> vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]])
|
|
504
|
+
>>> faces = np.array([[0, 1, 2]])
|
|
505
|
+
>>> mesh = surface.create_mesh_from_arrays(vertices, faces)
|
|
506
|
+
>>> print(f"Created mesh with {mesh.n_points} vertices")
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
# Validate array shapes
|
|
510
|
+
if vertices.ndim != 2 or vertices.shape[1] != 3:
|
|
511
|
+
raise ValueError("Vertices array must have shape (n_vertices, 3)")
|
|
512
|
+
|
|
513
|
+
if faces.ndim != 2 or faces.shape[1] != 3:
|
|
514
|
+
raise ValueError("Faces array must have shape (n_faces, 3)")
|
|
515
|
+
|
|
516
|
+
# Check that face indices are valid
|
|
517
|
+
if np.any(faces >= len(vertices)) or np.any(faces < 0):
|
|
518
|
+
raise ValueError(
|
|
519
|
+
"Face indices must be valid indices into the vertices array"
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
mesh = self._create_pyvista_mesh(vertices, faces)
|
|
523
|
+
|
|
524
|
+
return mesh
|
|
525
|
+
|
|
526
|
+
##############################################################################################
|
|
527
|
+
def _create_pyvista_mesh(
|
|
528
|
+
self, vertices: np.ndarray, faces: np.ndarray
|
|
529
|
+
) -> pv.PolyData:
|
|
530
|
+
"""
|
|
531
|
+
Internal method to create PyVista mesh from vertices and faces.
|
|
532
|
+
|
|
533
|
+
Parameters
|
|
534
|
+
----------
|
|
535
|
+
vertices : np.ndarray
|
|
536
|
+
Vertex coordinates with shape (n_vertices, 3).
|
|
537
|
+
|
|
538
|
+
faces : np.ndarray
|
|
539
|
+
Face connectivity with shape (n_faces, 3).
|
|
540
|
+
|
|
541
|
+
Returns
|
|
542
|
+
-------
|
|
543
|
+
pv.PolyData
|
|
544
|
+
PyVista mesh object with default surface colors.
|
|
545
|
+
|
|
546
|
+
Notes
|
|
547
|
+
-----
|
|
548
|
+
Handles PyVista-specific formatting requirements including adding
|
|
549
|
+
the face size prefix and setting up default point data.
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
# Add column with 3's to faces array for PyVista
|
|
553
|
+
faces_pv = np.c_[np.full(len(faces), 3), faces]
|
|
554
|
+
|
|
555
|
+
mesh = pv.PolyData(vertices, faces_pv)
|
|
556
|
+
vertices_colors = (
|
|
557
|
+
np.ones((len(vertices), 3), dtype=np.uint8) * 240
|
|
558
|
+
) # Default colors
|
|
559
|
+
mesh.point_data["surface"] = np.c_[
|
|
560
|
+
vertices_colors, np.ones(len(vertices), dtype=np.uint8) * 255
|
|
561
|
+
]
|
|
562
|
+
return mesh
|
|
563
|
+
|
|
564
|
+
##############################################################################################
|
|
565
|
+
def get_vertices(self) -> np.ndarray:
|
|
566
|
+
"""
|
|
567
|
+
Get vertex coordinates from the surface mesh.
|
|
568
|
+
|
|
569
|
+
Returns
|
|
570
|
+
-------
|
|
571
|
+
np.ndarray
|
|
572
|
+
Array of vertex coordinates with shape (n_vertices, 3).
|
|
573
|
+
|
|
574
|
+
Raises
|
|
575
|
+
------
|
|
576
|
+
RuntimeError
|
|
577
|
+
If no surface data has been loaded.
|
|
578
|
+
|
|
579
|
+
Examples
|
|
580
|
+
--------
|
|
581
|
+
>>> surface = Surface('lh.pial')
|
|
582
|
+
>>> vertices = surface.get_vertices()
|
|
583
|
+
>>> print(f"Surface has {len(vertices)} vertices")
|
|
584
|
+
>>> print(f"First vertex: {vertices[0]}")
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
if not self.is_loaded():
|
|
588
|
+
raise RuntimeError("No surface data loaded. Load data first.")
|
|
589
|
+
return self.mesh.points
|
|
590
|
+
|
|
591
|
+
##############################################################################################
|
|
592
|
+
def get_faces(self) -> np.ndarray:
|
|
593
|
+
"""
|
|
594
|
+
Get face connectivity from the surface mesh.
|
|
595
|
+
|
|
596
|
+
Returns
|
|
597
|
+
-------
|
|
598
|
+
np.ndarray
|
|
599
|
+
Array of face indices with shape (n_faces, 3). Each row contains
|
|
600
|
+
three vertex indices forming a triangular face.
|
|
601
|
+
|
|
602
|
+
Raises
|
|
603
|
+
------
|
|
604
|
+
RuntimeError
|
|
605
|
+
If no surface data has been loaded.
|
|
606
|
+
|
|
607
|
+
Notes
|
|
608
|
+
-----
|
|
609
|
+
Extracts face connectivity from PyVista's internal format which stores
|
|
610
|
+
faces as [n_vertices, vertex_id1, vertex_id2, ...]. This method returns
|
|
611
|
+
only the vertex indices in standard format.
|
|
612
|
+
|
|
613
|
+
Examples
|
|
614
|
+
--------
|
|
615
|
+
>>> surface = Surface('lh.pial')
|
|
616
|
+
>>> faces = surface.get_faces()
|
|
617
|
+
>>> print(f"Surface has {len(faces)} triangular faces")
|
|
618
|
+
>>> print(f"First face connects vertices: {faces[0]}")
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
if not self.is_loaded():
|
|
622
|
+
raise RuntimeError("No surface data loaded. Load data first.")
|
|
623
|
+
|
|
624
|
+
# PyVista stores faces as [n_vertices, vertex_id1, vertex_id2, ...]
|
|
625
|
+
# We need to extract just the vertices indices
|
|
626
|
+
faces_raw = self.mesh.faces
|
|
627
|
+
n_faces = self.mesh.n_cells
|
|
628
|
+
faces = faces_raw.reshape(n_faces, 4)[
|
|
629
|
+
:, 1:4
|
|
630
|
+
] # Skip the first column (n_vertices)
|
|
631
|
+
return faces
|
|
632
|
+
|
|
633
|
+
###############################################################################################
|
|
634
|
+
def get_edges(self, return_counts: bool = False) -> np.ndarray:
|
|
635
|
+
"""
|
|
636
|
+
Extract unique edges from a triangular mesh using vectorized operations.
|
|
637
|
+
|
|
638
|
+
This function efficiently extracts all unique edges from a triangular mesh
|
|
639
|
+
represented as a faces array. Each triangle contributes three edges, and
|
|
640
|
+
the function automatically removes duplicates that occur when triangles
|
|
641
|
+
share edges.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
faces : np.ndarray of shape (n_faces, 3)
|
|
646
|
+
Array where each row represents a triangular face defined by three
|
|
647
|
+
vertex indices. Vertex indices should be non-negative integers.
|
|
648
|
+
|
|
649
|
+
return_counts : bool, optional
|
|
650
|
+
If True, also return the count of how many faces each edge belongs to.
|
|
651
|
+
This is useful for identifying boundary edges (count=1) vs interior
|
|
652
|
+
edges (count=2). Default is False.
|
|
653
|
+
|
|
654
|
+
Returns
|
|
655
|
+
-------
|
|
656
|
+
edges : np.ndarray of shape (n_edges, 2)
|
|
657
|
+
Array of unique edges where each row contains two vertex indices
|
|
658
|
+
[v1, v2] with v1 <= v2. Edges are sorted lexicographically.
|
|
659
|
+
|
|
660
|
+
counts : np.ndarray of shape (n_edges,), optional
|
|
661
|
+
Number of faces that contain each edge. Only returned if
|
|
662
|
+
return_counts=True. Boundary edges have count=1, interior edges
|
|
663
|
+
have count=2.
|
|
664
|
+
|
|
665
|
+
Raises
|
|
666
|
+
------
|
|
667
|
+
ValueError
|
|
668
|
+
If faces array does not have exactly 3 columns (not triangular).
|
|
669
|
+
If faces array is empty.
|
|
670
|
+
If faces array contains negative indices.
|
|
671
|
+
|
|
672
|
+
Examples
|
|
673
|
+
--------
|
|
674
|
+
>>> # Simple triangular mesh with 2 triangles sharing an edge
|
|
675
|
+
>>> faces = np.array([[0, 1, 2], [1, 3, 2]])
|
|
676
|
+
>>> edges = get_edges(faces)
|
|
677
|
+
>>> print(edges)
|
|
678
|
+
[[0 1]
|
|
679
|
+
[0 2]
|
|
680
|
+
[1 2]
|
|
681
|
+
[1 3]
|
|
682
|
+
[2 3]]
|
|
683
|
+
|
|
684
|
+
>>> # Get edge counts to identify boundary vs interior edges
|
|
685
|
+
>>> edges, counts = get_edges(faces, return_counts=True)
|
|
686
|
+
>>> boundary_edges = edges[counts == 1]
|
|
687
|
+
>>> interior_edges = edges[counts == 2]
|
|
688
|
+
>>> print("Boundary edges:", boundary_edges)
|
|
689
|
+
>>> print("Interior edges:", interior_edges)
|
|
690
|
+
Boundary edges: [[0 1]
|
|
691
|
+
[0 2]
|
|
692
|
+
[1 3]
|
|
693
|
+
[2 3]]
|
|
694
|
+
Interior edges: [[1 2]]
|
|
695
|
+
|
|
696
|
+
>>> # Cube mesh (8 vertices, 12 triangular faces)
|
|
697
|
+
>>> cube_faces = np.array([
|
|
698
|
+
... [0, 1, 2], [0, 2, 3], # Bottom face
|
|
699
|
+
... [4, 5, 6], [4, 6, 7], # Top face
|
|
700
|
+
... [0, 1, 5], [0, 5, 4], # Front face
|
|
701
|
+
... [2, 3, 7], [2, 7, 6], # Back face
|
|
702
|
+
... [0, 3, 7], [0, 7, 4], # Left face
|
|
703
|
+
... [1, 2, 6], [1, 6, 5] # Right face
|
|
704
|
+
... ])
|
|
705
|
+
>>> edges = get_edges(cube_faces)
|
|
706
|
+
>>> print(f"Cube has {len(edges)} unique edges")
|
|
707
|
+
Cube has 18 unique edges
|
|
708
|
+
|
|
709
|
+
Notes
|
|
710
|
+
-----
|
|
711
|
+
This function uses vectorized NumPy operations for high performance on
|
|
712
|
+
large meshes. The algorithm:
|
|
713
|
+
|
|
714
|
+
1. Extracts all three edges from each triangle simultaneously
|
|
715
|
+
2. Sorts vertex pairs to canonical form (smaller index first)
|
|
716
|
+
3. Uses numpy.unique to efficiently remove duplicates
|
|
717
|
+
|
|
718
|
+
Time complexity: O(n log n) where n is the number of faces
|
|
719
|
+
Space complexity: O(n) for intermediate arrays
|
|
720
|
+
|
|
721
|
+
For non-triangular meshes, use the general `extract_edges_from_faces`
|
|
722
|
+
function instead.
|
|
723
|
+
|
|
724
|
+
The canonical edge representation ensures that edge (i, j) and edge (j, i)
|
|
725
|
+
are treated as the same edge, with the final representation always having
|
|
726
|
+
the smaller vertex index first.
|
|
727
|
+
|
|
728
|
+
See Also
|
|
729
|
+
--------
|
|
730
|
+
extract_edges_from_faces : General version for arbitrary polygon meshes
|
|
731
|
+
numpy.unique : Used internally for deduplication
|
|
732
|
+
"""
|
|
733
|
+
|
|
734
|
+
# Getting the faces array from the mesh
|
|
735
|
+
faces = self.mesh.faces[
|
|
736
|
+
:, 1:4
|
|
737
|
+
] # Extract only the vertex indices, skip the first column
|
|
738
|
+
|
|
739
|
+
# Input validation
|
|
740
|
+
if faces.size == 0:
|
|
741
|
+
raise ValueError("Faces array cannot be empty")
|
|
742
|
+
|
|
743
|
+
if faces.ndim != 2 or faces.shape[1] != 3:
|
|
744
|
+
raise ValueError(
|
|
745
|
+
f"Faces array must have shape (n_faces, 3), got {faces.shape}"
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if np.any(faces < 0):
|
|
749
|
+
raise ValueError("Faces array cannot contain negative vertex indices")
|
|
750
|
+
|
|
751
|
+
# Extract all edges from all triangles using vectorized operations
|
|
752
|
+
# Each triangle contributes 3 edges: (v0,v1), (v1,v2), (v2,v0)
|
|
753
|
+
all_edges = np.concatenate(
|
|
754
|
+
[
|
|
755
|
+
faces[:, [0, 1]], # Edge from vertex 0 to vertex 1
|
|
756
|
+
faces[:, [1, 2]], # Edge from vertex 1 to vertex 2
|
|
757
|
+
faces[:, [2, 0]], # Edge from vertex 2 to vertex 0
|
|
758
|
+
],
|
|
759
|
+
axis=0,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Sort each edge to canonical form (smaller vertex index first)
|
|
763
|
+
# This ensures (i,j) and (j,i) are treated as the same edge
|
|
764
|
+
canonical_edges = np.sort(all_edges, axis=1)
|
|
765
|
+
|
|
766
|
+
# Remove duplicate edges and optionally count occurrences
|
|
767
|
+
if return_counts:
|
|
768
|
+
unique_edges, counts = np.unique(
|
|
769
|
+
canonical_edges, axis=0, return_counts=True
|
|
770
|
+
)
|
|
771
|
+
return unique_edges, counts
|
|
772
|
+
else:
|
|
773
|
+
unique_edges = np.unique(canonical_edges, axis=0)
|
|
774
|
+
return unique_edges
|
|
775
|
+
|
|
776
|
+
###############################################################################################
|
|
777
|
+
def get_boundary_edges(self) -> np.ndarray:
|
|
778
|
+
"""
|
|
779
|
+
Extract only the boundary edges from a triangular mesh.
|
|
780
|
+
|
|
781
|
+
Boundary edges are those that belong to only one triangle, indicating
|
|
782
|
+
the mesh boundary or holes in the mesh.
|
|
783
|
+
|
|
784
|
+
Parameters
|
|
785
|
+
----------
|
|
786
|
+
faces : np.ndarray of shape (n_faces, 3)
|
|
787
|
+
Triangular mesh faces array.
|
|
788
|
+
|
|
789
|
+
Returns
|
|
790
|
+
-------
|
|
791
|
+
boundary_edges : np.ndarray of shape (n_boundary_edges, 2)
|
|
792
|
+
Array of boundary edges where each edge belongs to only one face.
|
|
793
|
+
|
|
794
|
+
Examples
|
|
795
|
+
--------
|
|
796
|
+
>>> # Mesh with a hole (incomplete sphere)
|
|
797
|
+
>>> faces = np.array([[0, 1, 2], [1, 3, 2], [3, 4, 2]])
|
|
798
|
+
>>> boundary = get_boundary_edges(faces)
|
|
799
|
+
>>> print("Boundary edges:", boundary)
|
|
800
|
+
"""
|
|
801
|
+
|
|
802
|
+
# Getting the faces array from the mesh
|
|
803
|
+
faces = self.mesh.faces[
|
|
804
|
+
:, 1:4
|
|
805
|
+
] # Extract only the vertex indices, skip the first column
|
|
806
|
+
|
|
807
|
+
edges, counts = self.get_edges(faces, return_counts=True)
|
|
808
|
+
return edges[counts == 1]
|
|
809
|
+
|
|
810
|
+
###############################################################################################
|
|
811
|
+
def get_manifold_edges(self) -> np.ndarray:
|
|
812
|
+
"""
|
|
813
|
+
Extract only the manifold (interior) edges from a triangular mesh.
|
|
814
|
+
|
|
815
|
+
Manifold edges are those shared by exactly two triangles, indicating
|
|
816
|
+
proper mesh topology without boundaries or non-manifold geometry.
|
|
817
|
+
|
|
818
|
+
Parameters
|
|
819
|
+
----------
|
|
820
|
+
faces : np.ndarray of shape (n_faces, 3)
|
|
821
|
+
Triangular mesh faces array.
|
|
822
|
+
|
|
823
|
+
Returns
|
|
824
|
+
-------
|
|
825
|
+
manifold_edges : np.ndarray of shape (n_manifold_edges, 2)
|
|
826
|
+
Array of manifold edges where each edge belongs to exactly two faces.
|
|
827
|
+
|
|
828
|
+
Examples
|
|
829
|
+
--------
|
|
830
|
+
>>> # Closed mesh (tetrahedron)
|
|
831
|
+
>>> faces = np.array([[0, 1, 2], [0, 2, 3], [0, 3, 1], [1, 2, 3]])
|
|
832
|
+
>>> manifold = get_manifold_edges(faces)
|
|
833
|
+
>>> print("Manifold edges:", manifold)
|
|
834
|
+
"""
|
|
835
|
+
# Getting the faces array from the mesh
|
|
836
|
+
faces = self.mesh.faces[:, 1:4] # Extract only the vertex indices
|
|
837
|
+
|
|
838
|
+
edges, counts = self.get_edges(faces, return_counts=True)
|
|
839
|
+
return edges[counts == 2]
|
|
840
|
+
|
|
841
|
+
##############################################################################################
|
|
842
|
+
def compute_normals(self) -> None:
|
|
843
|
+
"""
|
|
844
|
+
Compute and store vertex normals for the surface mesh.
|
|
845
|
+
|
|
846
|
+
Calculates unit normal vectors for each vertex and stores them in the
|
|
847
|
+
mesh point data under the key "Normals". Normals are automatically
|
|
848
|
+
normalized to unit length.
|
|
849
|
+
|
|
850
|
+
Raises
|
|
851
|
+
------
|
|
852
|
+
RuntimeError
|
|
853
|
+
If no surface data has been loaded.
|
|
854
|
+
RuntimeError
|
|
855
|
+
If computed normals have zero length and cannot be normalized.
|
|
856
|
+
|
|
857
|
+
Notes
|
|
858
|
+
-----
|
|
859
|
+
Uses PyVista's built-in normal computation which averages face normals
|
|
860
|
+
at each vertex. The resulting normals are forced to be unit vectors.
|
|
861
|
+
Overwrites any existing normals in the mesh.
|
|
862
|
+
|
|
863
|
+
Examples
|
|
864
|
+
--------
|
|
865
|
+
>>> surface = Surface('lh.pial')
|
|
866
|
+
>>> surface.compute_normals()
|
|
867
|
+
>>> normals = surface.get_normals()
|
|
868
|
+
>>> print(f"Computed {len(normals)} unit normal vectors")
|
|
869
|
+
>>>
|
|
870
|
+
>>> # Check that normals are unit vectors
|
|
871
|
+
>>> norms = np.linalg.norm(normals, axis=1)
|
|
872
|
+
>>> print(f"Normal lengths range: {norms.min():.3f} - {norms.max():.3f}")
|
|
873
|
+
"""
|
|
874
|
+
|
|
875
|
+
if not self.is_loaded():
|
|
876
|
+
raise RuntimeError("No surface data loaded. Load data first.")
|
|
877
|
+
|
|
878
|
+
self.mesh.compute_normals(inplace=True) # Compute normals and store in mesh
|
|
879
|
+
|
|
880
|
+
# Force the normals to be unit vectors
|
|
881
|
+
if "Normals" in self.mesh.point_data:
|
|
882
|
+
normals = self.mesh.point_data["Normals"]
|
|
883
|
+
norms = np.linalg.norm(normals, axis=1)
|
|
884
|
+
if np.any(norms > 0):
|
|
885
|
+
self.mesh.point_data["Normals"] = normals / norms[:, np.newaxis]
|
|
886
|
+
else:
|
|
887
|
+
raise RuntimeError(
|
|
888
|
+
"Computed normals have zero length. Cannot normalize."
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
##############################################################################################
|
|
892
|
+
def get_normals(self) -> Optional[np.ndarray]:
|
|
893
|
+
"""
|
|
894
|
+
Get vertex normals from the surface mesh if available.
|
|
895
|
+
|
|
896
|
+
Returns
|
|
897
|
+
-------
|
|
898
|
+
np.ndarray or None
|
|
899
|
+
Array of normal vectors with shape (n_vertices, 3) if normals
|
|
900
|
+
have been computed, None otherwise.
|
|
901
|
+
|
|
902
|
+
Notes
|
|
903
|
+
-----
|
|
904
|
+
Returns None if normals haven't been computed yet. Use compute_normals()
|
|
905
|
+
to calculate normals before calling this method.
|
|
906
|
+
|
|
907
|
+
Examples
|
|
908
|
+
--------
|
|
909
|
+
>>> surface = Surface('lh.pial')
|
|
910
|
+
>>> normals = surface.get_normals()
|
|
911
|
+
>>> if normals is not None:
|
|
912
|
+
... print(f"Found {len(normals)} normal vectors")
|
|
913
|
+
... else:
|
|
914
|
+
... print("No normals computed yet")
|
|
915
|
+
... surface.compute_normals()
|
|
916
|
+
... normals = surface.get_normals()
|
|
917
|
+
"""
|
|
918
|
+
|
|
919
|
+
if not self.is_loaded():
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
return self.mesh.point_data.get("Normals", None)
|
|
923
|
+
|
|
924
|
+
##############################################################################################
|
|
925
|
+
def load_annotation(
|
|
926
|
+
self,
|
|
927
|
+
annotation: Union[str, Path, cltfree.AnnotParcellation],
|
|
928
|
+
parc_name: str = None,
|
|
929
|
+
) -> None:
|
|
930
|
+
"""
|
|
931
|
+
Load parcellation annotation onto surface for visualization.
|
|
932
|
+
|
|
933
|
+
Loads FreeSurfer annotation files or AnnotParcellation objects,
|
|
934
|
+
storing labels and color information for region-based visualization.
|
|
935
|
+
|
|
936
|
+
Parameters
|
|
937
|
+
----------
|
|
938
|
+
annotation : str , Path or AnnotParcellation
|
|
939
|
+
Path to annotation file (.annot) or AnnotParcellation object.
|
|
940
|
+
|
|
941
|
+
parc_name : str
|
|
942
|
+
Name for parcellation reference in visualizations.
|
|
943
|
+
|
|
944
|
+
Raises
|
|
945
|
+
------
|
|
946
|
+
FileNotFoundError
|
|
947
|
+
If annotation file cannot be found.
|
|
948
|
+
|
|
949
|
+
ValueError
|
|
950
|
+
If invalid input type or vertex count mismatch.
|
|
951
|
+
|
|
952
|
+
Examples
|
|
953
|
+
--------
|
|
954
|
+
>>> # Load Desikan-Killiany parcellation
|
|
955
|
+
>>> surface.load_annotation('lh.aparc.annot', 'aparc')
|
|
956
|
+
>>>
|
|
957
|
+
>>> # Load from object
|
|
958
|
+
>>> annot = AnnotParcellation('lh.aparc.a2009s.annot')
|
|
959
|
+
>>> surface.load_annotation(annot, 'destrieux')
|
|
960
|
+
"""
|
|
961
|
+
|
|
962
|
+
if isinstance(annotation, Path):
|
|
963
|
+
annotation = str(annotation)
|
|
964
|
+
|
|
965
|
+
# Handle different input types
|
|
966
|
+
if isinstance(annotation, str):
|
|
967
|
+
# Input is a file path
|
|
968
|
+
if not os.path.isfile(annotation):
|
|
969
|
+
raise FileNotFoundError(f"Annotation file not found: {annotation}")
|
|
970
|
+
|
|
971
|
+
# Create AnnotParcellation object to benefit from its processing and cleaning
|
|
972
|
+
annot_parc = cltfree.AnnotParcellation()
|
|
973
|
+
annot_parc.load_from_file(parc_file=annotation, annot_id=parc_name)
|
|
974
|
+
|
|
975
|
+
elif (
|
|
976
|
+
hasattr(annotation, "codes")
|
|
977
|
+
and hasattr(annotation, "regtable")
|
|
978
|
+
and hasattr(annotation, "regnames")
|
|
979
|
+
):
|
|
980
|
+
# Input is an AnnotParcellation object
|
|
981
|
+
annot_parc = copy.deepcopy(annotation)
|
|
982
|
+
if parc_name is not None:
|
|
983
|
+
annot_parc.id = parc_name
|
|
984
|
+
|
|
985
|
+
else:
|
|
986
|
+
raise ValueError(
|
|
987
|
+
"annot_input must be either a file path (str) or an AnnotParcellation object"
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
# Extract the processed and cleaned data from AnnotParcellation
|
|
991
|
+
labels = annot_parc.codes
|
|
992
|
+
reg_ctable = annot_parc.regtable.astype(np.float32) # Ensure colors are float32
|
|
993
|
+
reg_names = annot_parc.regnames # Already processed as strings
|
|
994
|
+
|
|
995
|
+
# Validate that the number of vertices matches
|
|
996
|
+
if len(labels) != self.mesh.n_points:
|
|
997
|
+
raise ValueError(
|
|
998
|
+
f"Number of vertices in annotation ({len(labels)}) does not match surface ({self.mesh.n_points})"
|
|
999
|
+
)
|
|
1000
|
+
# If parc_name is not provided, use the annot_id from AnnotParcellation
|
|
1001
|
+
|
|
1002
|
+
if parc_name is None:
|
|
1003
|
+
parc_name = annot_parc.id
|
|
1004
|
+
|
|
1005
|
+
# Store the parcellation data
|
|
1006
|
+
tmp_colors = reg_ctable[:, :3]
|
|
1007
|
+
|
|
1008
|
+
reg_ctable[:, :3] = reg_ctable[:, :3] / 255 # Ensure colors are between 0 and 1
|
|
1009
|
+
|
|
1010
|
+
# If all the opacity values are 0 set them to 1
|
|
1011
|
+
if np.all(reg_ctable[:, 3] == 0):
|
|
1012
|
+
reg_ctable[:, 3] = 1.0
|
|
1013
|
+
|
|
1014
|
+
self._store_parcellation_data(labels, reg_ctable, reg_names, parc_name)
|
|
1015
|
+
|
|
1016
|
+
# Store reference to AnnotParcellation object for advanced operations
|
|
1017
|
+
self.colortables[parc_name]["annot_object"] = annot_parc
|
|
1018
|
+
|
|
1019
|
+
##############################################################################################
|
|
1020
|
+
def _store_parcellation_data(
|
|
1021
|
+
self,
|
|
1022
|
+
labels: np.ndarray,
|
|
1023
|
+
reg_ctable: np.ndarray,
|
|
1024
|
+
reg_names: List[str],
|
|
1025
|
+
parc_name: str,
|
|
1026
|
+
) -> None:
|
|
1027
|
+
"""
|
|
1028
|
+
Store parcellation data and create color mappings.
|
|
1029
|
+
|
|
1030
|
+
Internal method for organizing parcellation labels, colors, and names
|
|
1031
|
+
in surface object for visualization and analysis.
|
|
1032
|
+
|
|
1033
|
+
Parameters
|
|
1034
|
+
----------
|
|
1035
|
+
labels : np.ndarray
|
|
1036
|
+
Label values for each vertex.
|
|
1037
|
+
|
|
1038
|
+
reg_ctable : np.ndarray
|
|
1039
|
+
Color table with RGBA values for each region.
|
|
1040
|
+
|
|
1041
|
+
reg_names : list
|
|
1042
|
+
Region names corresponding to color table.
|
|
1043
|
+
|
|
1044
|
+
parc_name : str
|
|
1045
|
+
Name of the parcellation.
|
|
1046
|
+
|
|
1047
|
+
Notes
|
|
1048
|
+
-----
|
|
1049
|
+
Stores labels in mesh point data and creates organized color table
|
|
1050
|
+
structure. Also calls color table creation for visualization.
|
|
1051
|
+
"""
|
|
1052
|
+
|
|
1053
|
+
# Store labels in mesh
|
|
1054
|
+
self.mesh.point_data[parc_name] = labels
|
|
1055
|
+
|
|
1056
|
+
# Store parcellation information in organized structure
|
|
1057
|
+
self.colortables[parc_name] = {
|
|
1058
|
+
"struct_names": reg_names,
|
|
1059
|
+
"color_table": reg_ctable,
|
|
1060
|
+
"lookup_table": None, # Will be populated by _create_parcellation_colortable if needed
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
##############################################################################################
|
|
1064
|
+
def _get_parcellation_data(
|
|
1065
|
+
self, annotation: Union[str, Path, cltfree.AnnotParcellation]
|
|
1066
|
+
) -> cltfree.AnnotParcellation:
|
|
1067
|
+
"""
|
|
1068
|
+
Load or retrieve parcellation data from annotation file or object.
|
|
1069
|
+
Handles both file paths and AnnotParcellation objects, ensuring
|
|
1070
|
+
consistent data retrieval for visualization and analysis.
|
|
1071
|
+
|
|
1072
|
+
Parameters
|
|
1073
|
+
----------
|
|
1074
|
+
annotation : str, Path or AnnotParcellation
|
|
1075
|
+
Path to annotation file (.annot) or AnnotParcellation object.
|
|
1076
|
+
|
|
1077
|
+
Returns
|
|
1078
|
+
-------
|
|
1079
|
+
cltfree.AnnotParcellation
|
|
1080
|
+
AnnotParcellation object containing parcellation data.
|
|
1081
|
+
|
|
1082
|
+
Raises
|
|
1083
|
+
------
|
|
1084
|
+
FileNotFoundError
|
|
1085
|
+
If annotation file cannot be found and no colortable is available.
|
|
1086
|
+
|
|
1087
|
+
ValueError
|
|
1088
|
+
If annotation input type is invalid or does not match expected formats.
|
|
1089
|
+
|
|
1090
|
+
Notes
|
|
1091
|
+
-----
|
|
1092
|
+
- If annotation is a file path, it checks for existence and loads the data.
|
|
1093
|
+
|
|
1094
|
+
- If annotation is an AnnotParcellation object, it returns a deep copy to avoid
|
|
1095
|
+
modifying the original object.
|
|
1096
|
+
|
|
1097
|
+
- If the annotation file is not found, it attempts to load from colortables
|
|
1098
|
+
if available.
|
|
1099
|
+
- If no colortable is available, it raises a FileNotFoundError.
|
|
1100
|
+
|
|
1101
|
+
Examples
|
|
1102
|
+
--------
|
|
1103
|
+
>>> # Load from annotation file
|
|
1104
|
+
>>> annot_parc = surface._get_parcellation_data('lh.aparc.annot')
|
|
1105
|
+
>>>
|
|
1106
|
+
"""
|
|
1107
|
+
|
|
1108
|
+
if isinstance(annotation, Path):
|
|
1109
|
+
annotation = str(annotation)
|
|
1110
|
+
|
|
1111
|
+
# If map_name is not provided, use the first column name from the DataFrame
|
|
1112
|
+
if isinstance(annotation, str):
|
|
1113
|
+
# Check if the annotation file exists
|
|
1114
|
+
if not os.path.isfile(annotation) and annotation in self.mesh.point_data:
|
|
1115
|
+
# If the annotation file is not found, try to load it from the colortables
|
|
1116
|
+
|
|
1117
|
+
# Extract the annotation data
|
|
1118
|
+
maps_array = self.mesh.point_data[annotation]
|
|
1119
|
+
|
|
1120
|
+
# If there is a colortable for this map, use it
|
|
1121
|
+
if annotation in self.colortables:
|
|
1122
|
+
ctable = self.colortables[annotation]["color_table"]
|
|
1123
|
+
struct_names = self.colortables[annotation]["struct_names"]
|
|
1124
|
+
|
|
1125
|
+
# Create AnnotParcellation object from the data
|
|
1126
|
+
parc = cltfree.AnnotParcellation()
|
|
1127
|
+
parc.create_from_data(maps_array, ctable, struct_names)
|
|
1128
|
+
else:
|
|
1129
|
+
raise FileNotFoundError(
|
|
1130
|
+
f"Annotation file not found: {annotation} and no colortable available"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
elif os.path.isfile(annotation):
|
|
1134
|
+
# Create AnnotParcellation object to benefit from its processing and cleaning
|
|
1135
|
+
parc = cltfree.AnnotParcellation()
|
|
1136
|
+
parc.load_from_file(parc_file=annotation)
|
|
1137
|
+
else:
|
|
1138
|
+
raise FileNotFoundError(f"Annotation file not found: {annotation}")
|
|
1139
|
+
|
|
1140
|
+
elif isinstance(annotation, cltfree.AnnotParcellation):
|
|
1141
|
+
parc = copy.deepcopy(
|
|
1142
|
+
annotation
|
|
1143
|
+
) # Use a copy to avoid modifying the original object
|
|
1144
|
+
|
|
1145
|
+
return parc
|
|
1146
|
+
|
|
1147
|
+
##############################################################################################
|
|
1148
|
+
def load_scalar_maps(
|
|
1149
|
+
self,
|
|
1150
|
+
scalar_map: Union[str, Path, np.ndarray, pd.DataFrame],
|
|
1151
|
+
annotation: Union[str, Path, cltfree.AnnotParcellation] = None,
|
|
1152
|
+
maps_names: Union[str, List[str]] = None,
|
|
1153
|
+
) -> None:
|
|
1154
|
+
"""
|
|
1155
|
+
Load data from a FreeSurfer vertex-wise map, a numpy array, a CSV file or
|
|
1156
|
+
pandas Dataframe onto surface for visualization.
|
|
1157
|
+
|
|
1158
|
+
Handles both vertex-wise data (one value per vertex) and region-wise data
|
|
1159
|
+
(requires annotation for mapping to vertices). It is important that the CSV file
|
|
1160
|
+
has a header row with column names because the first row is used to name the maps.
|
|
1161
|
+
|
|
1162
|
+
If it contains region-wise data then the Annotation file is mandatory.
|
|
1163
|
+
|
|
1164
|
+
Parameters
|
|
1165
|
+
----------
|
|
1166
|
+
scalar_map : str, Path, pd.DataFrame
|
|
1167
|
+
Path to a FreeSurfer vertex-wise map file, a CSV file, a numpy array or a
|
|
1168
|
+
pandas DataFrame.
|
|
1169
|
+
|
|
1170
|
+
annotation : str, Path or AnnotParcellation, optional
|
|
1171
|
+
Annotation file/object for mapping region data to vertices.
|
|
1172
|
+
Required if the Dataframe has region-wise data. Default is None.
|
|
1173
|
+
|
|
1174
|
+
maps_names : str or list, optional
|
|
1175
|
+
Names for scalar data. If None, uses column names from CSV.
|
|
1176
|
+
Default is None.
|
|
1177
|
+
|
|
1178
|
+
Raises
|
|
1179
|
+
------
|
|
1180
|
+
FileNotFoundError
|
|
1181
|
+
If map file or annotation file cannot be found.
|
|
1182
|
+
|
|
1183
|
+
ValueError
|
|
1184
|
+
If annot_file required but not provided or invalid type.
|
|
1185
|
+
|
|
1186
|
+
ValueError
|
|
1187
|
+
If maps_names length does not match number of columns in CSV.
|
|
1188
|
+
|
|
1189
|
+
Notes
|
|
1190
|
+
-----
|
|
1191
|
+
Automatically detects if the array, the CSV or the array contains vertex-wise
|
|
1192
|
+
or region-wise data based on the number of rows. If the number of rows matches
|
|
1193
|
+
the number of vertices in the mesh, it is treated as vertex-wise data.
|
|
1194
|
+
Otherwise, it is treated as region-wise data and requires an annotation file to
|
|
1195
|
+
map region values to vertices.
|
|
1196
|
+
|
|
1197
|
+
The annotation can be provided as a file path, a string or as an AnnotParcellation object.
|
|
1198
|
+
If the annotation file is not found, it will try to load it from the colortables
|
|
1199
|
+
associated with the surface.
|
|
1200
|
+
|
|
1201
|
+
If maps_names is not provided, it uses the column names from the CSV file.
|
|
1202
|
+
If the annotation is provided as a string, it will try to load it as an AnnotParcellation
|
|
1203
|
+
object. If it is provided as an AnnotParcellation object, it will use it directly.
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
Examples
|
|
1207
|
+
--------
|
|
1208
|
+
>>> surf_lh = cltsurf.Surface("/opt/freesurfer/subjects/fsaverage/surf/lh.pial")
|
|
1209
|
+
|
|
1210
|
+
>>> # Example 1: Reading a region-wise map from a CSV file and selecting a specific column name
|
|
1211
|
+
>>> print("Example 1: Reading a region-wise map from a CSV file with a specific column name")
|
|
1212
|
+
>>> surf_lh.load_maps_scalar_maps("/tmp/values.csv",
|
|
1213
|
+
annotation="/opt/freesurfer/subjects/fsaverage/label/lh.aparc.annot",
|
|
1214
|
+
maps_names="region_index")
|
|
1215
|
+
|
|
1216
|
+
>>> print("Loaded maps from CSV file for an specific column name:")
|
|
1217
|
+
>>> print(surf_lh.list_overlays())
|
|
1218
|
+
>>> print("")
|
|
1219
|
+
|
|
1220
|
+
>>> # Example 2: Reading a region-wise map from a dataframe and selecting a column with specified name
|
|
1221
|
+
>>> import pandas as pd
|
|
1222
|
+
>>> values_df = pd.read_csv("/tmp/values.csv")
|
|
1223
|
+
>>> print("Example 2: Reading a region-wise map from a DataFrame with specified names")
|
|
1224
|
+
>>> surf_lh.load_maps_scalar_maps(values_df,
|
|
1225
|
+
annotation="/opt/freesurfer/subjects/fsaverage/label/lh.aparc.annot",
|
|
1226
|
+
maps_names=["value"])
|
|
1227
|
+
|
|
1228
|
+
>>> print(" Loaded maps from DataFrame with specified names:")
|
|
1229
|
+
>>> print(surf_lh.list_overlays())
|
|
1230
|
+
>>> print("")
|
|
1231
|
+
|
|
1232
|
+
>>> # Example 3: Reading a region-wise map from a numpy array without specifiying names
|
|
1233
|
+
>>> import pandas as pd
|
|
1234
|
+
>>> print("Example 3: Reading a region-wise map from a numpy array without specifying names")
|
|
1235
|
+
>>> values_df = pd.read_csv("/tmp/values.csv")
|
|
1236
|
+
>>> surf_lh.load_maps_scalar_maps(values_df.to_numpy(),
|
|
1237
|
+
annotation="/opt/freesurfer/subjects/fsaverage/label/lh.aparc.annot")
|
|
1238
|
+
|
|
1239
|
+
>>> print("Loaded maps from numpy array without specifying names:")
|
|
1240
|
+
>>> print(surf_lh.list_overlays())
|
|
1241
|
+
>>> print("")
|
|
1242
|
+
|
|
1243
|
+
>>> ######### Creating a csv with values as the number of vertices
|
|
1244
|
+
>>> import pandas as pd
|
|
1245
|
+
>>> n_points = surf_lh.mesh.n_points
|
|
1246
|
+
>>> values_df = pd.DataFrame({'vertex_index': np.arange(n_points), 'vertex_value': np.random.rand(n_points)})
|
|
1247
|
+
|
|
1248
|
+
>>> values_df.to_csv("/tmp/values-vertexwise.csv", index=False)
|
|
1249
|
+
|
|
1250
|
+
>>> # Example 4: Loading vertex-wise maps from a CSV file
|
|
1251
|
+
>>> print("Example 4: Loading vertex-wise maps from a CSV file")
|
|
1252
|
+
>>> surf_lh.load_maps_scalar_maps("/tmp/values-vertexwise.csv"
|
|
1253
|
+
)
|
|
1254
|
+
>>> print("Loaded vertex-wise maps from CSV file:")
|
|
1255
|
+
>>> print(surf_lh.list_overlays())
|
|
1256
|
+
>>> print("")
|
|
1257
|
+
|
|
1258
|
+
>>> # Example 5: Reading a region-wise map from a numpy array and an specified name
|
|
1259
|
+
>>> print("Example 5: Reading a region-wise map from a numpy array with specified names")
|
|
1260
|
+
>>> import numpy as np
|
|
1261
|
+
>>> values_array = np.random.rand(n_points)
|
|
1262
|
+
>>> surf_lh.load_maps_scalar_maps(values_array,
|
|
1263
|
+
maps_names=["ex5_vertex_value_array"])
|
|
1264
|
+
>>> print("Loaded vertex-wise maps from numpy array:")
|
|
1265
|
+
>>> print(surf_lh.list_overlays())
|
|
1266
|
+
>>> print("")
|
|
1267
|
+
|
|
1268
|
+
>>> # Example 6: Creating a numpy array without specifying names
|
|
1269
|
+
>>> values_array = np.random.rand(n_points)
|
|
1270
|
+
>>> print("Example 6: Creating a numpy array with values as the number of vertices without specifying names")
|
|
1271
|
+
>>> surf_lh.load_maps_scalar_maps(values_array)
|
|
1272
|
+
>>> print("Loaded vertex-wise maps from numpy array without specifying names:")
|
|
1273
|
+
>>> print(surf_lh.list_overlays())
|
|
1274
|
+
>>> print("")
|
|
1275
|
+
|
|
1276
|
+
>>> # Example 7: Reading a FreeSurfer map file
|
|
1277
|
+
>>> print("Example 7: Reading a FreeSurfer map file")
|
|
1278
|
+
>>> surf_lh.load_maps_scalar_maps("/opt/freesurfer/subjects/fsaverage/surf/lh.thickness",
|
|
1279
|
+
maps_names=["cthickness"])
|
|
1280
|
+
>>> print("Loaded vertex-wise maps from FreeSurfer map file:")
|
|
1281
|
+
>>> print(surf_lh.list_overlays())
|
|
1282
|
+
>>> print("")
|
|
1283
|
+
|
|
1284
|
+
>>> # Example 8: Reading multiple FreeSurfer map files
|
|
1285
|
+
>>> print("Example 8: Reading multiple FreeSurfer map files specifying names")
|
|
1286
|
+
>>> list_of_maps = [
|
|
1287
|
+
"/opt/freesurfer/subjects/fsaverage/surf/lh.thickness",
|
|
1288
|
+
"/opt/freesurfer/subjects/fsaverage/surf/lh.curv",
|
|
1289
|
+
"/opt/freesurfer/subjects/fsaverage/surf/lh.sulc"]
|
|
1290
|
+
|
|
1291
|
+
>>> maps_names = ["thickness", "curvature", "sulc"]
|
|
1292
|
+
|
|
1293
|
+
>>> for i, map_file in enumerate(list_of_maps):
|
|
1294
|
+
surf_lh.load_maps_scalar_maps(map_file, maps_names=maps_names[i])
|
|
1295
|
+
>>> print("Loaded vertex-wise maps from FreeSurfer map files:")
|
|
1296
|
+
>>> print(surf_lh.list_overlays())
|
|
1297
|
+
>>> print("")
|
|
1298
|
+
"""
|
|
1299
|
+
|
|
1300
|
+
if maps_names is not None:
|
|
1301
|
+
if isinstance(maps_names, str):
|
|
1302
|
+
maps_names = [maps_names]
|
|
1303
|
+
|
|
1304
|
+
# Load the scalar map based on its type
|
|
1305
|
+
try:
|
|
1306
|
+
if isinstance(scalar_map, pd.DataFrame):
|
|
1307
|
+
# If map_file is a DataFrame, use it directly
|
|
1308
|
+
maps_df = copy.deepcopy(scalar_map)
|
|
1309
|
+
|
|
1310
|
+
# Filter the columns that that are equal to the maps_names if maps_names is provided
|
|
1311
|
+
if maps_names is not None:
|
|
1312
|
+
maps_df = maps_df[maps_names]
|
|
1313
|
+
|
|
1314
|
+
elif isinstance(scalar_map, np.ndarray):
|
|
1315
|
+
|
|
1316
|
+
if scalar_map.ndim == 1:
|
|
1317
|
+
# If it is a row vector convert it to a column vector
|
|
1318
|
+
# If scalar_map is a 1D numpy array, convert it to a 2D array
|
|
1319
|
+
scalar_map = scalar_map[:, np.newaxis]
|
|
1320
|
+
|
|
1321
|
+
# If the maps names are not provided, create default names
|
|
1322
|
+
if maps_names is None:
|
|
1323
|
+
# Create default names for the maps
|
|
1324
|
+
tmp_names = [f"map_{i}" for i in range(scalar_map.shape[1])]
|
|
1325
|
+
maps_df = pd.DataFrame(scalar_map, columns=tmp_names)
|
|
1326
|
+
else:
|
|
1327
|
+
if len(maps_names) != scalar_map.shape[1]:
|
|
1328
|
+
raise ValueError(
|
|
1329
|
+
"Length of maps_names must match the number of columns in the numpy array"
|
|
1330
|
+
)
|
|
1331
|
+
# If scalar_map is a numpy array, convert it to a DataFrame
|
|
1332
|
+
maps_df = pd.DataFrame(scalar_map, columns=maps_names)
|
|
1333
|
+
|
|
1334
|
+
else:
|
|
1335
|
+
if isinstance(scalar_map, Path):
|
|
1336
|
+
scalar_map = str(scalar_map)
|
|
1337
|
+
|
|
1338
|
+
if not os.path.isfile(scalar_map):
|
|
1339
|
+
raise FileNotFoundError(f"Map file not found: {scalar_map}")
|
|
1340
|
+
|
|
1341
|
+
# Read the map file into a DataFrame
|
|
1342
|
+
maps_df = cltmisc.smart_read_table(scalar_map)
|
|
1343
|
+
if maps_names is not None:
|
|
1344
|
+
maps_df = maps_df[maps_names]
|
|
1345
|
+
|
|
1346
|
+
if annotation is not None:
|
|
1347
|
+
if isinstance(annotation, Path):
|
|
1348
|
+
annotation = str(annotation)
|
|
1349
|
+
|
|
1350
|
+
if not isinstance(annotation, (str, cltfree.AnnotParcellation)):
|
|
1351
|
+
raise ValueError(
|
|
1352
|
+
"annotation must be a string or an AnnotParcellation object"
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
if maps_names is not None:
|
|
1356
|
+
if isinstance(maps_names, str):
|
|
1357
|
+
maps_names = [maps_names]
|
|
1358
|
+
|
|
1359
|
+
elif not isinstance(maps_names, list):
|
|
1360
|
+
raise ValueError("maps_names must be a string or a list of strings")
|
|
1361
|
+
|
|
1362
|
+
if len(maps_names) != maps_df.shape[1]:
|
|
1363
|
+
raise ValueError(
|
|
1364
|
+
"Length of maps_names must match the number of columns in the DataFrame"
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
else:
|
|
1368
|
+
# If maps_names is not provided, use the column names from the DataFrame
|
|
1369
|
+
maps_names = maps_df.columns.tolist()
|
|
1370
|
+
|
|
1371
|
+
# If the number of rows of the dataframe is equal to the number of vertices, we can use it directly
|
|
1372
|
+
if maps_df.shape[0] == self.mesh.n_points:
|
|
1373
|
+
vertex_maps = maps_df.to_numpy()
|
|
1374
|
+
else:
|
|
1375
|
+
if annotation is None:
|
|
1376
|
+
raise ValueError(
|
|
1377
|
+
"annotation must be provided if map_file does not match the number of vertices"
|
|
1378
|
+
)
|
|
1379
|
+
# Extracting the parcellation data
|
|
1380
|
+
parc = self._get_parcellation_data(annotation)
|
|
1381
|
+
|
|
1382
|
+
vertex_maps = parc.map_values(
|
|
1383
|
+
regional_values=maps_df, is_dataframe=True
|
|
1384
|
+
).to_numpy()
|
|
1385
|
+
|
|
1386
|
+
for i, map_name in enumerate(maps_names):
|
|
1387
|
+
# Ensure the map data is a 1D array
|
|
1388
|
+
map_data = vertex_maps[:, i]
|
|
1389
|
+
|
|
1390
|
+
# Store the map data in the mesh point data
|
|
1391
|
+
self.mesh.point_data[map_name] = map_data
|
|
1392
|
+
|
|
1393
|
+
except:
|
|
1394
|
+
if isinstance(scalar_map, (str, Path)):
|
|
1395
|
+
if not os.path.isfile(scalar_map):
|
|
1396
|
+
raise FileNotFoundError(f"Map file not found: {scalar_map}")
|
|
1397
|
+
|
|
1398
|
+
# Read the map file
|
|
1399
|
+
tmp_map = nib.freesurfer.read_morph_data(str(scalar_map))
|
|
1400
|
+
|
|
1401
|
+
if tmp_map.shape[0] == self.mesh.n_points:
|
|
1402
|
+
if maps_names is None:
|
|
1403
|
+
# If maps_names is not provided, use the file name as the map name
|
|
1404
|
+
map_name = os.path.splitext(os.path.basename(scalar_map))[0]
|
|
1405
|
+
else:
|
|
1406
|
+
if len(maps_names) != 1:
|
|
1407
|
+
raise ValueError(
|
|
1408
|
+
"maps_names must be a single string or a list with one name"
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
self.mesh.point_data[maps_names[0]] = tmp_map
|
|
1412
|
+
|
|
1413
|
+
else:
|
|
1414
|
+
raise ValueError(
|
|
1415
|
+
f"Map file {scalar_map} does not match the number of vertices"
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
###############################################################################################
|
|
1419
|
+
def list_overlays(self) -> Dict[str, str]:
|
|
1420
|
+
"""
|
|
1421
|
+
List all available surface overlays and their data types.
|
|
1422
|
+
|
|
1423
|
+
Categorizes loaded data based on array dimensions and properties to
|
|
1424
|
+
identify scalar maps, color data, normals, and other overlay types.
|
|
1425
|
+
|
|
1426
|
+
Returns
|
|
1427
|
+
-------
|
|
1428
|
+
dict
|
|
1429
|
+
Dictionary mapping overlay names to their types:
|
|
1430
|
+
- 'scalar': 1D arrays of scalar values per vertex
|
|
1431
|
+
- 'color': 2D arrays with RGB color values (shape: n_vertices, 3)
|
|
1432
|
+
- 'normals': 2D arrays with unit normal vectors (shape: n_vertices, 3)
|
|
1433
|
+
- 'unknown': Arrays with other dimensions or unrecognized format
|
|
1434
|
+
|
|
1435
|
+
Notes
|
|
1436
|
+
-----
|
|
1437
|
+
Automatically detects data type based on:
|
|
1438
|
+
- 1D arrays: Classified as scalar data
|
|
1439
|
+
- 2D arrays with 3 columns: Checked for unit vectors (normals) vs colors
|
|
1440
|
+
- Other dimensions: Classified as unknown
|
|
1441
|
+
|
|
1442
|
+
Normal vectors are identified by having unit length (norm ≈ 1) and
|
|
1443
|
+
containing negative values.
|
|
1444
|
+
|
|
1445
|
+
Examples
|
|
1446
|
+
--------
|
|
1447
|
+
>>> # Load various data types
|
|
1448
|
+
>>> surface.load_scalar_map('thickness.mgh', 'thickness')
|
|
1449
|
+
>>> surface.load_annotation('aparc.annot', 'aparc')
|
|
1450
|
+
>>> surface.compute_normals()
|
|
1451
|
+
>>>
|
|
1452
|
+
>>> # List all overlays
|
|
1453
|
+
>>> overlays = surface.list_overlays()
|
|
1454
|
+
>>> print(overlays)
|
|
1455
|
+
{'surface': 'color', 'thickness': 'scalar', 'aparc': 'scalar', 'Normals': 'normals'}
|
|
1456
|
+
>>>
|
|
1457
|
+
>>> # Filter for scalar maps only
|
|
1458
|
+
>>> scalar_maps = {k: v for k, v in overlays.items() if v == 'scalar'}
|
|
1459
|
+
>>> print(f"Available scalar maps: {list(scalar_maps.keys())}")
|
|
1460
|
+
"""
|
|
1461
|
+
|
|
1462
|
+
overlays = {}
|
|
1463
|
+
|
|
1464
|
+
for key in self.mesh.point_data.keys():
|
|
1465
|
+
tmp = self.mesh.point_data[key]
|
|
1466
|
+
if isinstance(tmp, np.ndarray) and tmp.ndim == 1:
|
|
1467
|
+
|
|
1468
|
+
# If it's a 1D array, but has a color table, treat it as scalar with colortable
|
|
1469
|
+
if key in self.colortables:
|
|
1470
|
+
overlays[key] = "scalar_with_colortable"
|
|
1471
|
+
else:
|
|
1472
|
+
overlays[key] = "scalar"
|
|
1473
|
+
|
|
1474
|
+
elif isinstance(tmp, np.ndarray) and tmp.ndim == 2:
|
|
1475
|
+
if tmp.shape[1] == 3:
|
|
1476
|
+
# If there are negative values and the norm is equal to 1, it's likely normals
|
|
1477
|
+
if np.all(np.round(np.linalg.norm(tmp, axis=1)) == 1) and np.any(
|
|
1478
|
+
tmp < 0
|
|
1479
|
+
):
|
|
1480
|
+
overlays[key] = "normals"
|
|
1481
|
+
else:
|
|
1482
|
+
overlays[key] = "color"
|
|
1483
|
+
else:
|
|
1484
|
+
overlays[key] = "unknown"
|
|
1485
|
+
|
|
1486
|
+
return overlays
|
|
1487
|
+
|
|
1488
|
+
##############################################################################################
|
|
1489
|
+
def set_active_overlay(self, overlay_name: str) -> None:
|
|
1490
|
+
"""
|
|
1491
|
+
Set the active overlay for visualization.
|
|
1492
|
+
|
|
1493
|
+
Parameters
|
|
1494
|
+
----------
|
|
1495
|
+
overlay_name : str
|
|
1496
|
+
Name of the overlay to set as active
|
|
1497
|
+
|
|
1498
|
+
Returns
|
|
1499
|
+
-------
|
|
1500
|
+
None
|
|
1501
|
+
|
|
1502
|
+
Raises
|
|
1503
|
+
------
|
|
1504
|
+
ValueError
|
|
1505
|
+
If the specified overlay is not found in mesh point data
|
|
1506
|
+
|
|
1507
|
+
Examples
|
|
1508
|
+
--------
|
|
1509
|
+
>>> surface.set_active_overlay("thickness")
|
|
1510
|
+
>>> surface.set_active_overlay("aparc")
|
|
1511
|
+
"""
|
|
1512
|
+
if overlay_name not in self.mesh.point_data:
|
|
1513
|
+
raise ValueError(f"Overlay '{overlay_name}' not found in mesh point data")
|
|
1514
|
+
|
|
1515
|
+
self.mesh.set_active_scalars(overlay_name)
|
|
1516
|
+
|
|
1517
|
+
##############################################################################################
|
|
1518
|
+
def remove_overlay(self, overlay_name: str) -> None:
|
|
1519
|
+
"""
|
|
1520
|
+
Set the active overlay for visualization.
|
|
1521
|
+
|
|
1522
|
+
Designates which data array should be used as the primary scalar field
|
|
1523
|
+
for coloring and visualization in PyVista plots. This affects how the
|
|
1524
|
+
surface is colored when rendered.
|
|
1525
|
+
|
|
1526
|
+
Parameters
|
|
1527
|
+
----------
|
|
1528
|
+
overlay_name : str
|
|
1529
|
+
Name of the overlay to set as active. Must exist in mesh point data.
|
|
1530
|
+
|
|
1531
|
+
Raises
|
|
1532
|
+
------
|
|
1533
|
+
ValueError
|
|
1534
|
+
If the specified overlay is not found in mesh point data.
|
|
1535
|
+
|
|
1536
|
+
Notes
|
|
1537
|
+
-----
|
|
1538
|
+
The active overlay determines which data is used for:
|
|
1539
|
+
- Surface coloring in visualizations
|
|
1540
|
+
- Colormap application
|
|
1541
|
+
- Scalar value display in interactive plots
|
|
1542
|
+
|
|
1543
|
+
PyVista uses the active scalars for automatic coloring unless
|
|
1544
|
+
explicitly overridden in visualization methods.
|
|
1545
|
+
|
|
1546
|
+
Examples
|
|
1547
|
+
--------
|
|
1548
|
+
>>> # Set thickness as active for visualization
|
|
1549
|
+
>>> surface.set_active_overlay('thickness')
|
|
1550
|
+
>>>
|
|
1551
|
+
>>> # Switch to parcellation display
|
|
1552
|
+
>>> surface.set_active_overlay('aparc')
|
|
1553
|
+
>>>
|
|
1554
|
+
>>> # Check available overlays first
|
|
1555
|
+
>>> overlays = surface.list_overlays()
|
|
1556
|
+
>>> if 'curvature' in overlays:
|
|
1557
|
+
... surface.set_active_overlay('curvature')
|
|
1558
|
+
"""
|
|
1559
|
+
|
|
1560
|
+
# Check if overlay exists
|
|
1561
|
+
if (
|
|
1562
|
+
overlay_name not in self.mesh.point_data
|
|
1563
|
+
and overlay_name not in self.colortables
|
|
1564
|
+
):
|
|
1565
|
+
raise ValueError(f"Overlay '{overlay_name}' not found")
|
|
1566
|
+
|
|
1567
|
+
# Remove from mesh point data
|
|
1568
|
+
if overlay_name in self.mesh.point_data:
|
|
1569
|
+
del self.mesh.point_data[overlay_name]
|
|
1570
|
+
|
|
1571
|
+
# Remove from colortables storage
|
|
1572
|
+
if overlay_name in self.colortables:
|
|
1573
|
+
del self.colortables[overlay_name]
|
|
1574
|
+
|
|
1575
|
+
# If this was the active scalar, reset to surface default
|
|
1576
|
+
try:
|
|
1577
|
+
active_scalars = self.mesh.active_scalars_name
|
|
1578
|
+
if active_scalars == overlay_name:
|
|
1579
|
+
if "surface" in self.mesh.point_data:
|
|
1580
|
+
self.mesh.set_active_scalars("surface")
|
|
1581
|
+
else:
|
|
1582
|
+
# Find the first available overlay
|
|
1583
|
+
remaining_overlays = list(self.mesh.point_data.keys())
|
|
1584
|
+
if remaining_overlays:
|
|
1585
|
+
self.mesh.set_active_scalars(remaining_overlays[0])
|
|
1586
|
+
except:
|
|
1587
|
+
# If there's any issue with active scalars, just continue
|
|
1588
|
+
pass
|
|
1589
|
+
|
|
1590
|
+
##############################################################################################
|
|
1591
|
+
def get_overlay_info(self, overlay_name: str) -> Dict:
|
|
1592
|
+
"""
|
|
1593
|
+
Get information about a specific surface overlay.
|
|
1594
|
+
|
|
1595
|
+
Parameters
|
|
1596
|
+
----------
|
|
1597
|
+
overlay_name : str
|
|
1598
|
+
Name of the overlay to query.
|
|
1599
|
+
|
|
1600
|
+
Returns
|
|
1601
|
+
-------
|
|
1602
|
+
Dict
|
|
1603
|
+
Dictionary containing overlay metadata with keys:
|
|
1604
|
+
|
|
1605
|
+
- 'name' : str
|
|
1606
|
+
Name of the overlay.
|
|
1607
|
+
- 'data_shape' : tuple
|
|
1608
|
+
Shape of the overlay data array.
|
|
1609
|
+
- 'data_type' : str
|
|
1610
|
+
NumPy data type of the overlay values.
|
|
1611
|
+
- 'has_colortable' : bool
|
|
1612
|
+
Whether the overlay has an associated color table.
|
|
1613
|
+
- 'num_regions' : int, optional
|
|
1614
|
+
Number of regions (if parcellation overlay).
|
|
1615
|
+
- 'region_names' : list of str, optional
|
|
1616
|
+
Names of regions (if parcellation overlay).
|
|
1617
|
+
- 'has_annot_object' : bool, optional
|
|
1618
|
+
Whether annotation object is available (if parcellation overlay).
|
|
1619
|
+
|
|
1620
|
+
Raises
|
|
1621
|
+
------
|
|
1622
|
+
ValueError
|
|
1623
|
+
If the overlay is not found.
|
|
1624
|
+
|
|
1625
|
+
Examples
|
|
1626
|
+
--------
|
|
1627
|
+
>>> surface = Surface()
|
|
1628
|
+
>>> info = surface.get_overlay_info("aparc")
|
|
1629
|
+
>>> print(f"Overlay has {info['num_regions']} regions")
|
|
1630
|
+
>>> print(f"Data type: {info['data_type']}")
|
|
1631
|
+
"""
|
|
1632
|
+
|
|
1633
|
+
if overlay_name not in self.mesh.point_data:
|
|
1634
|
+
raise ValueError(f"Overlay '{overlay_name}' not found")
|
|
1635
|
+
|
|
1636
|
+
info = {
|
|
1637
|
+
"name": overlay_name,
|
|
1638
|
+
"data_shape": self.mesh.point_data[overlay_name].shape,
|
|
1639
|
+
"data_type": str(self.mesh.point_data[overlay_name].dtype),
|
|
1640
|
+
"has_colortable": overlay_name in self.colortables,
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
# Add colortable info if available
|
|
1644
|
+
if overlay_name in self.colortables:
|
|
1645
|
+
ctable_info = self.colortables[overlay_name]
|
|
1646
|
+
info["num_regions"] = len(ctable_info["struct_names"])
|
|
1647
|
+
info["region_names"] = ctable_info["struct_names"]
|
|
1648
|
+
info["has_annot_object"] = "annot_object" in ctable_info
|
|
1649
|
+
|
|
1650
|
+
return info
|
|
1651
|
+
|
|
1652
|
+
##############################################################################################
|
|
1653
|
+
def get_region_vertices(self, parc_name: str, region_name: str) -> np.ndarray:
|
|
1654
|
+
"""
|
|
1655
|
+
Get vertices indices for a specific region in a parcellation.
|
|
1656
|
+
|
|
1657
|
+
Parameters
|
|
1658
|
+
----------
|
|
1659
|
+
parc_name : str
|
|
1660
|
+
Name of the parcellation
|
|
1661
|
+
|
|
1662
|
+
region_name : str
|
|
1663
|
+
Name of the region
|
|
1664
|
+
|
|
1665
|
+
Returns
|
|
1666
|
+
-------
|
|
1667
|
+
np.ndarray
|
|
1668
|
+
Array of vertices indices belonging to the region
|
|
1669
|
+
|
|
1670
|
+
Raises
|
|
1671
|
+
------
|
|
1672
|
+
ValueError
|
|
1673
|
+
If the parcellation is not found
|
|
1674
|
+
|
|
1675
|
+
ValueError
|
|
1676
|
+
If the region is not found in the parcellation
|
|
1677
|
+
|
|
1678
|
+
Examples
|
|
1679
|
+
--------
|
|
1680
|
+
>>> # Get vertices in the precentral gyrus
|
|
1681
|
+
>>> vertices = surface.get_region_vertices("aparc", "precentral")
|
|
1682
|
+
>>> print(f"Precentral region has {len(vertices)} vertices")
|
|
1683
|
+
>>>
|
|
1684
|
+
>>> # Get all vertices in superior frontal region
|
|
1685
|
+
>>> vertices = surface.get_region_vertices("aparc", "superiorfrontal")
|
|
1686
|
+
"""
|
|
1687
|
+
if parc_name not in self.colortables:
|
|
1688
|
+
raise ValueError(f"Parcellation '{parc_name}' not found")
|
|
1689
|
+
|
|
1690
|
+
# Use AnnotParcellation object if available for more robust lookup
|
|
1691
|
+
if "annot_object" in self.colortables[parc_name]:
|
|
1692
|
+
annot_obj = self.colortables[parc_name]["annot_object"]
|
|
1693
|
+
return annot_obj.get_region_vertices(region_name)
|
|
1694
|
+
else:
|
|
1695
|
+
# Fallback to manual lookup
|
|
1696
|
+
if region_name not in self.colortables[parc_name]["struct_names"]:
|
|
1697
|
+
raise ValueError(
|
|
1698
|
+
f"Region '{region_name}' not found in parcellation '{parc_name}'"
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
# Find the label value for this region
|
|
1702
|
+
region_idx = self.colortables[parc_name]["struct_names"].index(region_name)
|
|
1703
|
+
label_value = self.colortables[parc_name]["color_table"][region_idx, 4]
|
|
1704
|
+
|
|
1705
|
+
# Get vertices with this label
|
|
1706
|
+
labels = self.mesh.point_data[parc_name]
|
|
1707
|
+
return np.where(labels == label_value)[0]
|
|
1708
|
+
|
|
1709
|
+
##############################################################################################
|
|
1710
|
+
def get_region_info(self, parc_name: str, region_name: str) -> Dict:
|
|
1711
|
+
"""
|
|
1712
|
+
Get comprehensive information about a region in a parcellation.
|
|
1713
|
+
|
|
1714
|
+
Parameters
|
|
1715
|
+
----------
|
|
1716
|
+
parc_name : str
|
|
1717
|
+
Name of the parcellation
|
|
1718
|
+
|
|
1719
|
+
region_name : str
|
|
1720
|
+
Name of the region
|
|
1721
|
+
|
|
1722
|
+
Returns
|
|
1723
|
+
-------
|
|
1724
|
+
Dict
|
|
1725
|
+
Dictionary with region information containing:
|
|
1726
|
+
- 'name': str, region name
|
|
1727
|
+
- 'index': int, region index in parcellation
|
|
1728
|
+
- 'label_value': int, label value used in annotation
|
|
1729
|
+
- 'color_rgb': np.ndarray, RGB color values (0-255)
|
|
1730
|
+
- 'color_rgba': np.ndarray, RGBA color values (0-255)
|
|
1731
|
+
- 'vertex_count': int, number of vertices in region
|
|
1732
|
+
- 'vertex_indices': np.ndarray, indices of vertices in region
|
|
1733
|
+
|
|
1734
|
+
Raises
|
|
1735
|
+
------
|
|
1736
|
+
ValueError
|
|
1737
|
+
If the parcellation or region is not found
|
|
1738
|
+
|
|
1739
|
+
Examples
|
|
1740
|
+
--------
|
|
1741
|
+
>>> info = surface.get_region_info("aparc", "precentral")
|
|
1742
|
+
>>> print(f"Region: {info['name']}")
|
|
1743
|
+
>>> print(f"Vertices: {info['vertex_count']}")
|
|
1744
|
+
>>> print(f"Color: {info['color_rgb']}")
|
|
1745
|
+
"""
|
|
1746
|
+
if parc_name not in self.colortables:
|
|
1747
|
+
raise ValueError(f"Parcellation '{parc_name}' not found")
|
|
1748
|
+
|
|
1749
|
+
# Use AnnotParcellation object if available
|
|
1750
|
+
if "annot_object" in self.colortables[parc_name]:
|
|
1751
|
+
annot_obj = self.colortables[parc_name]["annot_object"]
|
|
1752
|
+
return annot_obj.get_region_info(region_name)
|
|
1753
|
+
else:
|
|
1754
|
+
# Fallback to manual calculation
|
|
1755
|
+
vertices = self.get_region_vertices(parc_name, region_name)
|
|
1756
|
+
region_idx = self.colortables[parc_name]["struct_names"].index(region_name)
|
|
1757
|
+
color_table = self.colortables[parc_name]["color_table"]
|
|
1758
|
+
|
|
1759
|
+
return {
|
|
1760
|
+
"name": region_name,
|
|
1761
|
+
"index": region_idx,
|
|
1762
|
+
"label_value": color_table[region_idx, 4],
|
|
1763
|
+
"color_rgb": color_table[region_idx, :3],
|
|
1764
|
+
"color_rgba": color_table[region_idx, :4],
|
|
1765
|
+
"vertex_count": len(vertices),
|
|
1766
|
+
"vertex_indices": vertices,
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
##############################################################################################
|
|
1770
|
+
def list_regions(self, parc_name: str) -> Union[pd.DataFrame, Dict]:
|
|
1771
|
+
"""
|
|
1772
|
+
Get a summary of all regions in a parcellation.
|
|
1773
|
+
|
|
1774
|
+
Parameters
|
|
1775
|
+
----------
|
|
1776
|
+
parc_name : str
|
|
1777
|
+
Name of the parcellation
|
|
1778
|
+
|
|
1779
|
+
Returns
|
|
1780
|
+
-------
|
|
1781
|
+
pd.DataFrame or Dict
|
|
1782
|
+
DataFrame with region information if AnnotParcellation object is available,
|
|
1783
|
+
otherwise a dictionary with basic information. Contains region names,
|
|
1784
|
+
label values, vertices counts, and colors.
|
|
1785
|
+
|
|
1786
|
+
Raises
|
|
1787
|
+
------
|
|
1788
|
+
ValueError
|
|
1789
|
+
If the parcellation is not found
|
|
1790
|
+
|
|
1791
|
+
Examples
|
|
1792
|
+
--------
|
|
1793
|
+
>>> regions = surface.list_regions("aparc")
|
|
1794
|
+
>>> if isinstance(regions, pd.DataFrame):
|
|
1795
|
+
... print(regions.head())
|
|
1796
|
+
... else:
|
|
1797
|
+
... for name, info in regions.items():
|
|
1798
|
+
... print(f"{name}: {info['vertex_count']} vertices")
|
|
1799
|
+
"""
|
|
1800
|
+
|
|
1801
|
+
if parc_name not in self.colortables:
|
|
1802
|
+
raise ValueError(f"Parcellation '{parc_name}' not found")
|
|
1803
|
+
|
|
1804
|
+
# Use AnnotParcellation object if available
|
|
1805
|
+
if "annot_object" in self.colortables[parc_name]:
|
|
1806
|
+
annot_obj = self.colortables[parc_name]["annot_object"]
|
|
1807
|
+
return annot_obj.list_regions()
|
|
1808
|
+
else:
|
|
1809
|
+
# Fallback to basic information
|
|
1810
|
+
ctable_info = self.colortables[parc_name]
|
|
1811
|
+
regions = {}
|
|
1812
|
+
for i, name in enumerate(ctable_info["struct_names"]):
|
|
1813
|
+
color_table = ctable_info["color_table"]
|
|
1814
|
+
vertices = self.get_region_vertices(parc_name, name)
|
|
1815
|
+
regions[name] = {
|
|
1816
|
+
"label_value": color_table[i, 4],
|
|
1817
|
+
"vertex_count": len(vertices),
|
|
1818
|
+
"color_rgb": color_table[i, :3].tolist(),
|
|
1819
|
+
}
|
|
1820
|
+
return regions
|
|
1821
|
+
|
|
1822
|
+
##############################################################################################
|
|
1823
|
+
def get_vertexwise_colors(
|
|
1824
|
+
self,
|
|
1825
|
+
overlay_name: str = "surface",
|
|
1826
|
+
colormap: str = "viridis",
|
|
1827
|
+
vmin: np.float64 = None,
|
|
1828
|
+
vmax: np.float64 = None,
|
|
1829
|
+
) -> None:
|
|
1830
|
+
"""
|
|
1831
|
+
Compute vertices colors for visualization based on the specified overlay.
|
|
1832
|
+
|
|
1833
|
+
This method processes the overlay data and creates appropiate vertices colors
|
|
1834
|
+
for visualization, handling both scalar data (with colormaps) and
|
|
1835
|
+
categorical data (with discrete color tables).
|
|
1836
|
+
|
|
1837
|
+
Parameters
|
|
1838
|
+
----------
|
|
1839
|
+
overlay_name : str, optional
|
|
1840
|
+
Name of the overlay to visualize. If None, the first available overlay is used.
|
|
1841
|
+
|
|
1842
|
+
colormap : str, optional
|
|
1843
|
+
Colormap to use for scalar overlays. If None, uses parcellation color table
|
|
1844
|
+
for categorical data or 'viridis' for scalar data.
|
|
1845
|
+
|
|
1846
|
+
vmin : np.float64, optional
|
|
1847
|
+
Minimum value for scaling the colormap. If None, uses the minimum value of the overlay
|
|
1848
|
+
|
|
1849
|
+
vmax : np.float64, optional
|
|
1850
|
+
Maximum value for scaling the colormap. If None, uses the maximum value of the overlay
|
|
1851
|
+
If both vmin and vmax are None, the colormap will be applied to the full range of the overlay values.
|
|
1852
|
+
If both are provided, they will be used to scale the colormap.
|
|
1853
|
+
|
|
1854
|
+
Returns
|
|
1855
|
+
-------
|
|
1856
|
+
vertices_colors : np.ndarray
|
|
1857
|
+
Array of RGBA colors for each vertex in the mesh.
|
|
1858
|
+
|
|
1859
|
+
Raises
|
|
1860
|
+
------
|
|
1861
|
+
ValueError
|
|
1862
|
+
If the specified overlay is not found in the mesh point data
|
|
1863
|
+
|
|
1864
|
+
ValueError
|
|
1865
|
+
If no overlays are available
|
|
1866
|
+
|
|
1867
|
+
Notes
|
|
1868
|
+
-----
|
|
1869
|
+
This method sets the vertices colors based on the specified overlay.
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
Examples
|
|
1873
|
+
--------
|
|
1874
|
+
>>> # Prepare colors for a parcellation (uses discrete colors)
|
|
1875
|
+
>>> surface.get_vertexwise_colors(overlay_name="aparc")
|
|
1876
|
+
>>>
|
|
1877
|
+
>>> # Prepare colors for scalar data with custom colormap
|
|
1878
|
+
>>> surface.get_vertexwise_colors(overlay_name="thickness", colormap="hot")
|
|
1879
|
+
>>>
|
|
1880
|
+
>>> # Prepare colors for the surface overlay
|
|
1881
|
+
>>> surface.get_vertexwise_colors()
|
|
1882
|
+
"""
|
|
1883
|
+
|
|
1884
|
+
# Get the list of overlays
|
|
1885
|
+
overlay_dict = self.list_overlays()
|
|
1886
|
+
|
|
1887
|
+
# If the dictionary is empty
|
|
1888
|
+
overlays = list(overlay_dict.keys())
|
|
1889
|
+
if overlay_name is None:
|
|
1890
|
+
overlay_name = overlays[0] if overlay_dict else None
|
|
1891
|
+
|
|
1892
|
+
if overlay_name not in overlays:
|
|
1893
|
+
raise ValueError(
|
|
1894
|
+
f"Overlay '{overlay_name}' not found. Available overlays: {', '.join(overlays)}"
|
|
1895
|
+
)
|
|
1896
|
+
|
|
1897
|
+
# Getting the values of the overlay
|
|
1898
|
+
vertex_values = self.mesh.point_data[overlay_name]
|
|
1899
|
+
|
|
1900
|
+
# if colortables is an attribute of the class, use it
|
|
1901
|
+
if hasattr(self, "colortables"):
|
|
1902
|
+
dict_ctables = self.colortables
|
|
1903
|
+
|
|
1904
|
+
# Check if the overlay is on the colortables
|
|
1905
|
+
if overlay_name in dict_ctables.keys():
|
|
1906
|
+
# Use the colortable associated with the parcellation
|
|
1907
|
+
|
|
1908
|
+
vertices_colors = cltmisc.get_colors_from_colortable(
|
|
1909
|
+
vertex_values, self.colortables[overlay_name]["color_table"]
|
|
1910
|
+
)
|
|
1911
|
+
else:
|
|
1912
|
+
# Use the colormap for scalar data
|
|
1913
|
+
vertices_colors = cltmisc.values2colors(
|
|
1914
|
+
vertex_values,
|
|
1915
|
+
cmap=colormap,
|
|
1916
|
+
output_format="rgb",
|
|
1917
|
+
vmin=vmin,
|
|
1918
|
+
vmax=vmax,
|
|
1919
|
+
)
|
|
1920
|
+
else:
|
|
1921
|
+
vertices_colors = cltmisc.values2colors(
|
|
1922
|
+
vertex_values,
|
|
1923
|
+
cmap=colormap,
|
|
1924
|
+
output_format="rgb",
|
|
1925
|
+
vmin=vmin,
|
|
1926
|
+
vmax=vmax,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1929
|
+
return vertices_colors
|
|
1930
|
+
|
|
1931
|
+
##############################################################################################
|
|
1932
|
+
def prepare_colors(
|
|
1933
|
+
self,
|
|
1934
|
+
overlay_name: str = None,
|
|
1935
|
+
cmap: str = "viridis",
|
|
1936
|
+
vmin: np.float64 = None,
|
|
1937
|
+
vmax: np.float64 = None,
|
|
1938
|
+
) -> None:
|
|
1939
|
+
"""
|
|
1940
|
+
Prepare vertices colors for visualization based on the specified overlay.
|
|
1941
|
+
|
|
1942
|
+
This method processes the overlay data and creates appropiate vertices colors
|
|
1943
|
+
for visualization, handling both scalar data (with colormaps) and
|
|
1944
|
+
categorical data (with discrete color tables).
|
|
1945
|
+
|
|
1946
|
+
Parameters
|
|
1947
|
+
----------
|
|
1948
|
+
overlay_name : str, optional
|
|
1949
|
+
Name of the overlay to visualize. If None, the first available overlay is used.
|
|
1950
|
+
|
|
1951
|
+
cmap : str, optional
|
|
1952
|
+
Colormap to use for scalar overlays. If None, uses parcellation color table
|
|
1953
|
+
for categorical data or 'viridis' for scalar data.
|
|
1954
|
+
|
|
1955
|
+
vmin : np.float64, optional
|
|
1956
|
+
Minimum value for scaling the colormap. If None, uses the minimum value of the overlay
|
|
1957
|
+
|
|
1958
|
+
vmax : np.float64, optional
|
|
1959
|
+
Maximum value for scaling the colormap. If None, uses the maximum value of the overlay
|
|
1960
|
+
If both vmin and vmax are None, the colormap will be applied to the full range of the overlay values.
|
|
1961
|
+
If both are provided, they will be used to scale the colormap.
|
|
1962
|
+
|
|
1963
|
+
Returns
|
|
1964
|
+
-------
|
|
1965
|
+
None
|
|
1966
|
+
|
|
1967
|
+
Raises
|
|
1968
|
+
------
|
|
1969
|
+
ValueError
|
|
1970
|
+
If the specified overlay is not found in the mesh point data
|
|
1971
|
+
ValueError
|
|
1972
|
+
If no overlays are available
|
|
1973
|
+
|
|
1974
|
+
Notes
|
|
1975
|
+
-----
|
|
1976
|
+
This method sets the vertices colors in the mesh based on the specified overlay.
|
|
1977
|
+
The colors are stored in the mesh's point_data under the key "RGB"
|
|
1978
|
+
and set as the active scalars for visualization.
|
|
1979
|
+
|
|
1980
|
+
Examples
|
|
1981
|
+
--------
|
|
1982
|
+
>>> # Prepare colors for a parcellation (uses discrete colors)
|
|
1983
|
+
>>> surface.prepare_colors(overlay_name="aparc")
|
|
1984
|
+
>>>
|
|
1985
|
+
>>> # Prepare colors for scalar data with custom colormap
|
|
1986
|
+
>>> surface.prepare_colors(overlay_name="thickness", cmap="hot")
|
|
1987
|
+
>>>
|
|
1988
|
+
>>> # Prepare colors for first available overlay
|
|
1989
|
+
>>> surface.prepare_colors()
|
|
1990
|
+
"""
|
|
1991
|
+
# Get the list of overlays
|
|
1992
|
+
overlay_dict = self.list_overlays()
|
|
1993
|
+
|
|
1994
|
+
# If the dictionary is empty
|
|
1995
|
+
overlays = list(overlay_dict.keys())
|
|
1996
|
+
if overlay_name is None:
|
|
1997
|
+
overlay_name = overlays[0] if overlay_dict else None
|
|
1998
|
+
|
|
1999
|
+
if overlay_name not in overlays:
|
|
2000
|
+
raise ValueError(
|
|
2001
|
+
f"Overlay '{overlay_name}' not found. Available overlays: {', '.join(overlays)}"
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
else:
|
|
2005
|
+
# Set the active overlay
|
|
2006
|
+
self.set_active_overlay(overlay_name)
|
|
2007
|
+
|
|
2008
|
+
if overlay_dict[overlay_name] == "color":
|
|
2009
|
+
self.mesh.point_data["RGB"] = self.mesh.point_data[overlay_name]
|
|
2010
|
+
self.mesh.set_active_scalars("RGB")
|
|
2011
|
+
return
|
|
2012
|
+
|
|
2013
|
+
else:
|
|
2014
|
+
# If no colormap is provided, use the default colormap for the overlay
|
|
2015
|
+
vertex_values = self.mesh.point_data[overlay_name]
|
|
2016
|
+
dict_ctables = self.colortables
|
|
2017
|
+
# Check if the overlay is a color or scalar type
|
|
2018
|
+
|
|
2019
|
+
if overlay_name in dict_ctables.keys():
|
|
2020
|
+
# Use the colortable associated with the parcellation
|
|
2021
|
+
vertex_colors = cltfree.create_vertex_colors(
|
|
2022
|
+
vertex_values, self.colortables[overlay_name]["color_table"]
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
else:
|
|
2026
|
+
vertex_colors = cltmisc.values2colors(
|
|
2027
|
+
vertex_values,
|
|
2028
|
+
cmap=cmap,
|
|
2029
|
+
output_format="rgb",
|
|
2030
|
+
vmin=vmin,
|
|
2031
|
+
vmax=vmax,
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
self.mesh.point_data["RGB"] = vertex_colors
|
|
2035
|
+
self.mesh.set_active_scalars("RGB")
|
|
2036
|
+
|
|
2037
|
+
##############################################################################################
|
|
2038
|
+
def merge_surfaces(self, surfaces: Union["Surface", List["Surface"]]) -> "Surface":
|
|
2039
|
+
"""
|
|
2040
|
+
Merge this surface with others into a single surface.
|
|
2041
|
+
|
|
2042
|
+
This method merges multiple Surface objects by combining their geometries
|
|
2043
|
+
and point data. Only point_data fields that are present in ALL surfaces
|
|
2044
|
+
are retained in the merged result.
|
|
2045
|
+
|
|
2046
|
+
Parameters
|
|
2047
|
+
----------
|
|
2048
|
+
surfaces : List[Surface]
|
|
2049
|
+
List of Surface objects to merge with this surface
|
|
2050
|
+
|
|
2051
|
+
Returns
|
|
2052
|
+
-------
|
|
2053
|
+
Surface
|
|
2054
|
+
New merged Surface object with hemisphere set to "unknown"
|
|
2055
|
+
|
|
2056
|
+
Raises
|
|
2057
|
+
------
|
|
2058
|
+
TypeError
|
|
2059
|
+
If surfaces is not a list or contains non-Surface objects
|
|
2060
|
+
ValueError
|
|
2061
|
+
If the surfaces list is empty
|
|
2062
|
+
|
|
2063
|
+
Examples
|
|
2064
|
+
--------
|
|
2065
|
+
>>> # Merge left and right hemisphere surfaces
|
|
2066
|
+
>>> lh_surf = Surface("lh.pial")
|
|
2067
|
+
>>> rh_surf = Surface("rh.pial")
|
|
2068
|
+
>>> merged = lh_surf.merge_surfaces([rh_surf])
|
|
2069
|
+
>>> print(f"Merged surface has {merged.mesh.n_points} vertices")
|
|
2070
|
+
>>>
|
|
2071
|
+
>>> # Merge multiple surfaces
|
|
2072
|
+
>>> surf1 = Surface("surface1.pial")
|
|
2073
|
+
>>> surf2 = Surface("surface2.pial")
|
|
2074
|
+
>>> surf3 = Surface("surface3.pial")
|
|
2075
|
+
>>> merged = surf1.merge_surfaces([surf2, surf3])
|
|
2076
|
+
"""
|
|
2077
|
+
|
|
2078
|
+
if not isinstance(surfaces, list):
|
|
2079
|
+
surfaces = [surfaces]
|
|
2080
|
+
|
|
2081
|
+
if len(surfaces) == 0:
|
|
2082
|
+
raise ValueError("surfaces list cannot be empty")
|
|
2083
|
+
|
|
2084
|
+
# Check that all items in the list are Surface objects
|
|
2085
|
+
for i, surf in enumerate(surfaces):
|
|
2086
|
+
if not isinstance(surf, Surface):
|
|
2087
|
+
raise TypeError(f"Item at index {i} is not a Surface object")
|
|
2088
|
+
|
|
2089
|
+
# Include this surface in the list
|
|
2090
|
+
all_surfaces = [self] + surfaces
|
|
2091
|
+
|
|
2092
|
+
# Find common point_data fields across all surfaces
|
|
2093
|
+
common_fields = None
|
|
2094
|
+
for surf in all_surfaces:
|
|
2095
|
+
current_fields = set(surf.mesh.point_data.keys())
|
|
2096
|
+
if common_fields is None:
|
|
2097
|
+
common_fields = current_fields
|
|
2098
|
+
else:
|
|
2099
|
+
common_fields = common_fields.intersection(current_fields)
|
|
2100
|
+
|
|
2101
|
+
# Convert to list for consistent ordering
|
|
2102
|
+
common_fields = list(common_fields)
|
|
2103
|
+
|
|
2104
|
+
# Prepare meshes with only common fields
|
|
2105
|
+
meshes_to_merge = []
|
|
2106
|
+
for surf in all_surfaces:
|
|
2107
|
+
# Create a copy of the mesh
|
|
2108
|
+
mesh_copy = copy.deepcopy(surf.mesh)
|
|
2109
|
+
|
|
2110
|
+
# Remove point_data fields that are not common
|
|
2111
|
+
fields_to_remove = set(mesh_copy.point_data.keys()) - set(common_fields)
|
|
2112
|
+
for field in fields_to_remove:
|
|
2113
|
+
del mesh_copy.point_data[field]
|
|
2114
|
+
|
|
2115
|
+
meshes_to_merge.append(mesh_copy)
|
|
2116
|
+
|
|
2117
|
+
# Merge all meshes using PyVista
|
|
2118
|
+
if len(meshes_to_merge) == 1:
|
|
2119
|
+
merged_mesh = meshes_to_merge[0]
|
|
2120
|
+
else:
|
|
2121
|
+
merged_mesh = pv.merge(meshes_to_merge)
|
|
2122
|
+
|
|
2123
|
+
# Create new Surface object without calling __init__
|
|
2124
|
+
merged_surface = Surface.__new__(Surface)
|
|
2125
|
+
merged_surface.mesh = merged_mesh
|
|
2126
|
+
merged_surface.hemi = "unknown"
|
|
2127
|
+
merged_surface.surf = "merged_surface"
|
|
2128
|
+
|
|
2129
|
+
# Merge colortables - only keep those for common fields
|
|
2130
|
+
merged_colortables = {}
|
|
2131
|
+
for surf in all_surfaces:
|
|
2132
|
+
for key, value in surf.colortables.items():
|
|
2133
|
+
if key in common_fields:
|
|
2134
|
+
# If key already exists, keep the first one encountered
|
|
2135
|
+
if key not in merged_colortables:
|
|
2136
|
+
# Deep copy the colortable data to avoid reference issues
|
|
2137
|
+
if isinstance(value, dict):
|
|
2138
|
+
merged_colortables[key] = {}
|
|
2139
|
+
for k, v in value.items():
|
|
2140
|
+
if isinstance(v, (list, np.ndarray)):
|
|
2141
|
+
merged_colortables[key][k] = v.copy()
|
|
2142
|
+
else:
|
|
2143
|
+
merged_colortables[key][k] = v
|
|
2144
|
+
else:
|
|
2145
|
+
merged_colortables[key] = value
|
|
2146
|
+
|
|
2147
|
+
merged_surface.colortables = merged_colortables
|
|
2148
|
+
|
|
2149
|
+
return merged_surface
|
|
2150
|
+
|
|
2151
|
+
##############################################################################################
|
|
2152
|
+
def save_surface(
|
|
2153
|
+
self,
|
|
2154
|
+
filename: str,
|
|
2155
|
+
format: str = "freesurfer",
|
|
2156
|
+
save_annotation: str = None,
|
|
2157
|
+
map_name: str = None,
|
|
2158
|
+
overwrite: bool = False,
|
|
2159
|
+
) -> None:
|
|
2160
|
+
"""
|
|
2161
|
+
Save the surface mesh to a file in the specified format.
|
|
2162
|
+
|
|
2163
|
+
Exports the surface geometry (vertices and faces) and optionally associated
|
|
2164
|
+
data to various file formats including FreeSurfer, VTK, PLY, STL, and OBJ.
|
|
2165
|
+
|
|
2166
|
+
Parameters
|
|
2167
|
+
----------
|
|
2168
|
+
filename : str
|
|
2169
|
+
Output filename with or without extension. Extension will be added
|
|
2170
|
+
automatically if missing for some formats.
|
|
2171
|
+
|
|
2172
|
+
format : str, default "freesurfer"
|
|
2173
|
+
Output format: 'freesurfer', 'vtk', 'ply', 'stl', or 'obj'.
|
|
2174
|
+
|
|
2175
|
+
save_annotation : str, optional
|
|
2176
|
+
Path to save annotation file (for parcellation data). Only applicable
|
|
2177
|
+
for FreeSurfer.
|
|
2178
|
+
|
|
2179
|
+
map_name : str, optional
|
|
2180
|
+
Name of overlay/parcellation to include with the surface data.
|
|
2181
|
+
|
|
2182
|
+
overwrite : bool, default False
|
|
2183
|
+
Whether to overwrite existing files.
|
|
2184
|
+
|
|
2185
|
+
Raises
|
|
2186
|
+
------
|
|
2187
|
+
ValueError
|
|
2188
|
+
If filename is invalid, format is unsupported, or file exists and
|
|
2189
|
+
overwrite is False.
|
|
2190
|
+
FileNotFoundError
|
|
2191
|
+
If the output directory does not exist.
|
|
2192
|
+
|
|
2193
|
+
Examples
|
|
2194
|
+
--------
|
|
2195
|
+
>>> surface.save_surface("lh.pial.vtk", format="vtk")
|
|
2196
|
+
>>> surface.save_surface("cortex.ply", format="ply", overwrite=True)
|
|
2197
|
+
"""
|
|
2198
|
+
|
|
2199
|
+
if not isinstance(filename, str):
|
|
2200
|
+
raise ValueError("filename must be a string")
|
|
2201
|
+
if not filename:
|
|
2202
|
+
raise ValueError("filename cannot be empty")
|
|
2203
|
+
|
|
2204
|
+
# Check if the filename exists as a valid path
|
|
2205
|
+
if os.path.exists(filename) and not overwrite:
|
|
2206
|
+
raise ValueError(
|
|
2207
|
+
f"File '{filename}' already exists. Please set overwrite to True or choose a different name."
|
|
2208
|
+
)
|
|
2209
|
+
|
|
2210
|
+
# Ensure the directory exists
|
|
2211
|
+
directory = os.path.dirname(filename)
|
|
2212
|
+
if directory and not os.path.exists(directory):
|
|
2213
|
+
raise FileNotFoundError(f"Directory '{directory}' does not exist")
|
|
2214
|
+
|
|
2215
|
+
# Save the mesh using PyVista's built-in methods
|
|
2216
|
+
if format.lower() == "freesurfer":
|
|
2217
|
+
self.export_to_freesurfer(filename, save_annotation, map_name, overwrite)
|
|
2218
|
+
|
|
2219
|
+
elif format.lower() == "obj":
|
|
2220
|
+
self.export_to_obj(filename, save_annotation, map_name, overwrite)
|
|
2221
|
+
|
|
2222
|
+
elif format.lower() in ["vtk", "ply", "stl"]:
|
|
2223
|
+
|
|
2224
|
+
# Substitute the file extension to match the format
|
|
2225
|
+
file_ext = os.path.splitext(filename)[1].lower()
|
|
2226
|
+
if file_ext not in [".vtk", ".ply", ".stl"]:
|
|
2227
|
+
if format.lower() == "vtk":
|
|
2228
|
+
filename += ".vtk"
|
|
2229
|
+
elif format.lower() == "ply":
|
|
2230
|
+
filename += ".ply"
|
|
2231
|
+
elif format.lower() == "stl":
|
|
2232
|
+
filename += ".stl"
|
|
2233
|
+
else:
|
|
2234
|
+
raise ValueError(
|
|
2235
|
+
f"Unsupported file format: {format}. Supported formats are 'vtk', 'ply', 'stl'."
|
|
2236
|
+
)
|
|
2237
|
+
|
|
2238
|
+
self.export_to_pyvista(filename, save_annotation, map_name, overwrite)
|
|
2239
|
+
# Print a message indicating the file was saved
|
|
2240
|
+
print(f"Surface saved to {filename}")
|
|
2241
|
+
|
|
2242
|
+
else:
|
|
2243
|
+
raise ValueError(
|
|
2244
|
+
f"Unsupported file format: {format}. Supported formats are 'vtk', 'ply', 'stl', 'obj', and 'freesurfer'."
|
|
2245
|
+
)
|
|
2246
|
+
|
|
2247
|
+
##############################################################################################
|
|
2248
|
+
def export_to_obj(
|
|
2249
|
+
self,
|
|
2250
|
+
filename: str,
|
|
2251
|
+
save_annotation: str = None,
|
|
2252
|
+
map_name: str = None,
|
|
2253
|
+
overwrite: bool = False,
|
|
2254
|
+
) -> None:
|
|
2255
|
+
"""
|
|
2256
|
+
Export the surface mesh to an OBJ file format.
|
|
2257
|
+
|
|
2258
|
+
Writes surface geometry as a Wavefront OBJ file, which stores vertices
|
|
2259
|
+
and triangular faces in a simple text format widely supported by 3D
|
|
2260
|
+
software and visualization tools.
|
|
2261
|
+
|
|
2262
|
+
Parameters
|
|
2263
|
+
----------
|
|
2264
|
+
filename : str
|
|
2265
|
+
Output filename, should end with .obj extension.
|
|
2266
|
+
|
|
2267
|
+
save_annotation : str, optional
|
|
2268
|
+
Path to save associated annotation file in FreeSurfer format.
|
|
2269
|
+
|
|
2270
|
+
map_name : str, optional
|
|
2271
|
+
Name of parcellation/overlay to export alongside the geometry.
|
|
2272
|
+
|
|
2273
|
+
overwrite : bool, default False
|
|
2274
|
+
Whether to overwrite existing files.
|
|
2275
|
+
|
|
2276
|
+
Raises
|
|
2277
|
+
------
|
|
2278
|
+
ValueError
|
|
2279
|
+
If filename is invalid or file exists and overwrite is False.
|
|
2280
|
+
FileNotFoundError
|
|
2281
|
+
If the output directory does not exist.
|
|
2282
|
+
|
|
2283
|
+
Notes
|
|
2284
|
+
-----
|
|
2285
|
+
OBJ format uses 1-based indexing for face connectivity. The exported
|
|
2286
|
+
file includes vertex coordinates and triangular face definitions.
|
|
2287
|
+
|
|
2288
|
+
Examples
|
|
2289
|
+
--------
|
|
2290
|
+
>>> surface.export_to_obj("brain_surface.obj")
|
|
2291
|
+
>>> surface.export_to_obj("lh.pial.obj", save_annotation="lh.aparc.annot", map_name="aparc")
|
|
2292
|
+
"""
|
|
2293
|
+
|
|
2294
|
+
# Validate filename
|
|
2295
|
+
if not isinstance(filename, str):
|
|
2296
|
+
raise ValueError("filename must be a string")
|
|
2297
|
+
if not filename:
|
|
2298
|
+
raise ValueError("filename cannot be empty")
|
|
2299
|
+
|
|
2300
|
+
# Check if the filename exists as a valid path
|
|
2301
|
+
if os.path.exists(filename) and not overwrite:
|
|
2302
|
+
raise ValueError(
|
|
2303
|
+
f"File '{filename}' already exists. Please set overwrite to True or choose a different name."
|
|
2304
|
+
)
|
|
2305
|
+
|
|
2306
|
+
# Ensure the directory exists
|
|
2307
|
+
directory = os.path.dirname(filename)
|
|
2308
|
+
if directory and not os.path.exists(directory):
|
|
2309
|
+
raise FileNotFoundError(f"Directory '{directory}' does not exist")
|
|
2310
|
+
|
|
2311
|
+
# If save_annotation is provided, save the annotation data
|
|
2312
|
+
if save_annotation is not None:
|
|
2313
|
+
self.export_annotation(
|
|
2314
|
+
filename=save_annotation, parc_name=map_name, overwrite=overwrite
|
|
2315
|
+
)
|
|
2316
|
+
|
|
2317
|
+
vertices = self.mesh.points
|
|
2318
|
+
faces = self.mesh.regular_faces
|
|
2319
|
+
|
|
2320
|
+
with open(filename, "w") as f:
|
|
2321
|
+
f.write(f"# OBJ file exported from Surface class\n")
|
|
2322
|
+
f.write(f"# Vertices: {len(vertices)}\n")
|
|
2323
|
+
f.write(f"# Faces: {len(faces)}\n\n")
|
|
2324
|
+
|
|
2325
|
+
# Write vertices
|
|
2326
|
+
for vertex in vertices:
|
|
2327
|
+
f.write(f"v {vertex[0]:.6f} {vertex[1]:.6f} {vertex[2]:.6f}\n")
|
|
2328
|
+
|
|
2329
|
+
# Write faces (OBJ uses 1-based indexing)
|
|
2330
|
+
f.write("\n")
|
|
2331
|
+
for face in faces:
|
|
2332
|
+
f.write(f"f {face[0]+1} {face[1]+1} {face[2]+1}\n")
|
|
2333
|
+
|
|
2334
|
+
print(f"Surface exported to {filename}")
|
|
2335
|
+
|
|
2336
|
+
def export_to_pyvista(
|
|
2337
|
+
self,
|
|
2338
|
+
filename: str,
|
|
2339
|
+
save_annotation: str = None,
|
|
2340
|
+
map_name: str = None,
|
|
2341
|
+
overwrite: bool = False,
|
|
2342
|
+
) -> None:
|
|
2343
|
+
"""
|
|
2344
|
+
Export surface to VTK, STL, or PLY format using PyVista.
|
|
2345
|
+
|
|
2346
|
+
Saves the surface mesh in formats supported by PyVista, preserving
|
|
2347
|
+
geometry and optionally scalar data or colors. The file format is
|
|
2348
|
+
determined by the filename extension.
|
|
2349
|
+
|
|
2350
|
+
Parameters
|
|
2351
|
+
----------
|
|
2352
|
+
filename : str
|
|
2353
|
+
Output filename with extension (.vtk, .ply, or .stl).
|
|
2354
|
+
|
|
2355
|
+
save_annotation : str, optional
|
|
2356
|
+
Path to save annotation file in FreeSurfer format.
|
|
2357
|
+
|
|
2358
|
+
map_name : str, optional
|
|
2359
|
+
Name of overlay to include as scalar data or vertex colors.
|
|
2360
|
+
|
|
2361
|
+
overwrite : bool, default False
|
|
2362
|
+
Whether to overwrite existing files.
|
|
2363
|
+
|
|
2364
|
+
Raises
|
|
2365
|
+
------
|
|
2366
|
+
ValueError
|
|
2367
|
+
If filename is invalid, map_name not found, or file exists and
|
|
2368
|
+
overwrite is False.
|
|
2369
|
+
FileNotFoundError
|
|
2370
|
+
If the output directory does not exist.
|
|
2371
|
+
|
|
2372
|
+
Notes
|
|
2373
|
+
-----
|
|
2374
|
+
VTK format can store additional scalar data and colors. PLY and STL
|
|
2375
|
+
formats primarily store geometry. When map_name is specified, the
|
|
2376
|
+
overlay data is prepared as vertex colors using associated colortables.
|
|
2377
|
+
|
|
2378
|
+
Examples
|
|
2379
|
+
--------
|
|
2380
|
+
>>> surface.export_to_pyvista("brain.vtk")
|
|
2381
|
+
>>> surface.export_to_pyvista("surface.ply", map_name="thickness")
|
|
2382
|
+
"""
|
|
2383
|
+
|
|
2384
|
+
# Validate filename
|
|
2385
|
+
if not isinstance(filename, str):
|
|
2386
|
+
raise ValueError("filename must be a string")
|
|
2387
|
+
if not filename:
|
|
2388
|
+
raise ValueError("filename cannot be empty")
|
|
2389
|
+
|
|
2390
|
+
# Check if the filename exists as a valid path
|
|
2391
|
+
if os.path.exists(filename) and not overwrite:
|
|
2392
|
+
raise ValueError(
|
|
2393
|
+
f"File '{filename}' already exists. Please set overwrite to True or choose a different name."
|
|
2394
|
+
)
|
|
2395
|
+
|
|
2396
|
+
# Ensure the directory exists
|
|
2397
|
+
directory = os.path.dirname(filename)
|
|
2398
|
+
if directory and not os.path.exists(directory):
|
|
2399
|
+
raise FileNotFoundError(f"Directory '{directory}' does not exist")
|
|
2400
|
+
|
|
2401
|
+
# If save_annotation is provided, save the annotation data
|
|
2402
|
+
if save_annotation is not None:
|
|
2403
|
+
self.export_annotation(
|
|
2404
|
+
filename=save_annotation, parc_name=map_name, overwrite=overwrite
|
|
2405
|
+
)
|
|
2406
|
+
|
|
2407
|
+
if map_name is not None:
|
|
2408
|
+
# Ensure the map_name is valid
|
|
2409
|
+
if not isinstance(map_name, str):
|
|
2410
|
+
raise ValueError("map_name must be a string")
|
|
2411
|
+
if not map_name:
|
|
2412
|
+
raise ValueError("map_name cannot be empty")
|
|
2413
|
+
|
|
2414
|
+
# Ensure the map_name exists in the mesh point data
|
|
2415
|
+
if map_name not in self.mesh.point_data:
|
|
2416
|
+
raise ValueError(f"Map '{map_name}' not found in mesh point data")
|
|
2417
|
+
|
|
2418
|
+
# Set the active scalars to the specified map_name
|
|
2419
|
+
self.mesh.set_active_scalars(map_name)
|
|
2420
|
+
|
|
2421
|
+
# Prepare colors
|
|
2422
|
+
self.prepare_colors(
|
|
2423
|
+
overlay_name=map_name,
|
|
2424
|
+
cmap=None, # Use the colortable for this map
|
|
2425
|
+
vmin=None,
|
|
2426
|
+
vmax=None,
|
|
2427
|
+
)
|
|
2428
|
+
|
|
2429
|
+
# Save the mesh (no texture parameter needed for vertices colors)
|
|
2430
|
+
self.mesh.save(filename)
|
|
2431
|
+
|
|
2432
|
+
else:
|
|
2433
|
+
# Use PyVista's built-in save method for VTK format
|
|
2434
|
+
self.mesh.save(filename)
|
|
2435
|
+
|
|
2436
|
+
##############################################################################################
|
|
2437
|
+
def export_to_freesurfer(
|
|
2438
|
+
self,
|
|
2439
|
+
filename: str,
|
|
2440
|
+
save_annotation: str = None,
|
|
2441
|
+
map_name: str = None,
|
|
2442
|
+
overwrite: bool = False,
|
|
2443
|
+
) -> None:
|
|
2444
|
+
"""
|
|
2445
|
+
Export surface to FreeSurfer binary format.
|
|
2446
|
+
|
|
2447
|
+
Saves the surface mesh in FreeSurfer's native binary geometry format,
|
|
2448
|
+
which efficiently stores vertex coordinates and triangular face
|
|
2449
|
+
connectivity for neuroimaging applications.
|
|
2450
|
+
|
|
2451
|
+
Parameters
|
|
2452
|
+
----------
|
|
2453
|
+
filename : str
|
|
2454
|
+
Output filename, typically without extension (e.g., 'lh.pial').
|
|
2455
|
+
save_annotation : str, optional
|
|
2456
|
+
Path to save annotation file containing parcellation data.
|
|
2457
|
+
map_name : str, optional
|
|
2458
|
+
Name of parcellation to export with the annotation file.
|
|
2459
|
+
overwrite : bool, default False
|
|
2460
|
+
Whether to overwrite existing files.
|
|
2461
|
+
|
|
2462
|
+
Raises
|
|
2463
|
+
------
|
|
2464
|
+
ValueError
|
|
2465
|
+
If filename is invalid or file exists and overwrite is False.
|
|
2466
|
+
FileNotFoundError
|
|
2467
|
+
If the output directory does not exist.
|
|
2468
|
+
|
|
2469
|
+
Notes
|
|
2470
|
+
-----
|
|
2471
|
+
FreeSurfer format is a compact binary representation optimized for
|
|
2472
|
+
neuroimaging workflows. The format stores only geometry data;
|
|
2473
|
+
additional data like parcellations are saved separately as .annot files.
|
|
2474
|
+
|
|
2475
|
+
Examples
|
|
2476
|
+
--------
|
|
2477
|
+
>>> surface.export_to_freesurfer("lh.pial")
|
|
2478
|
+
>>> surface.export_to_freesurfer("rh.white", save_annotation="rh.aparc.annot", map_name="aparc")
|
|
2479
|
+
"""
|
|
2480
|
+
|
|
2481
|
+
if not isinstance(filename, str):
|
|
2482
|
+
raise ValueError("filename must be a string")
|
|
2483
|
+
if not filename:
|
|
2484
|
+
raise ValueError("filename cannot be empty")
|
|
2485
|
+
|
|
2486
|
+
# Check if the filename exist as a valid path
|
|
2487
|
+
if os.path.exists(filename) and not overwrite:
|
|
2488
|
+
raise ValueError(
|
|
2489
|
+
f"File '{filename}' already exists. Please set overwrite to True or choose a different name."
|
|
2490
|
+
)
|
|
2491
|
+
|
|
2492
|
+
# Ensure the directory exists
|
|
2493
|
+
directory = os.path.dirname(filename)
|
|
2494
|
+
if directory and not os.path.exists(directory):
|
|
2495
|
+
raise FileNotFoundError(f"Directory '{directory}' does not exist")
|
|
2496
|
+
|
|
2497
|
+
# If save_annotation is provided, save the annotation data
|
|
2498
|
+
if save_annotation is not None:
|
|
2499
|
+
self.export_annotation(
|
|
2500
|
+
filename=save_annotation, parc_name=map_name, overwrite=overwrite
|
|
2501
|
+
)
|
|
2502
|
+
|
|
2503
|
+
# Separating vertices and faces
|
|
2504
|
+
vertices = self.mesh.points
|
|
2505
|
+
faces = self.mesh.regular_faces
|
|
2506
|
+
|
|
2507
|
+
# Use nibabel to write FreeSurfer geometry
|
|
2508
|
+
nib.freesurfer.write_geometry(filename, vertices, faces)
|
|
2509
|
+
|
|
2510
|
+
##############################################################################################
|
|
2511
|
+
def export_annotation(
|
|
2512
|
+
self,
|
|
2513
|
+
filename: str,
|
|
2514
|
+
parc_name: str,
|
|
2515
|
+
overwrite: bool = False,
|
|
2516
|
+
) -> None:
|
|
2517
|
+
"""
|
|
2518
|
+
Export parcellation data to a FreeSurfer annotation file.
|
|
2519
|
+
|
|
2520
|
+
Saves vertex-wise parcellation labels, associated color lookup table,
|
|
2521
|
+
and region names in FreeSurfer's .annot format for use with FreeSurfer
|
|
2522
|
+
tools and visualization software.
|
|
2523
|
+
|
|
2524
|
+
Parameters
|
|
2525
|
+
----------
|
|
2526
|
+
filename : str
|
|
2527
|
+
Output filename for annotation file (should end with .annot).
|
|
2528
|
+
|
|
2529
|
+
parc_name : str
|
|
2530
|
+
Name of parcellation overlay to export from the surface data.
|
|
2531
|
+
|
|
2532
|
+
overwrite : bool, default False
|
|
2533
|
+
Whether to overwrite existing files.
|
|
2534
|
+
|
|
2535
|
+
Raises
|
|
2536
|
+
------
|
|
2537
|
+
ValueError
|
|
2538
|
+
If filename or parc_name is invalid, parcellation not found, or
|
|
2539
|
+
file exists and overwrite is False.
|
|
2540
|
+
FileNotFoundError
|
|
2541
|
+
If the output directory does not exist.
|
|
2542
|
+
|
|
2543
|
+
Notes
|
|
2544
|
+
-----
|
|
2545
|
+
Requires the parcellation to have an associated colortable with region
|
|
2546
|
+
names and colors. The annotation format preserves the mapping between
|
|
2547
|
+
vertex labels, region names, and visualization colors.
|
|
2548
|
+
|
|
2549
|
+
Examples
|
|
2550
|
+
--------
|
|
2551
|
+
>>> surface.export_annotation("lh.aparc.annot", "aparc")
|
|
2552
|
+
>>> surface.export_annotation("rh.destrieux.annot", "destrieux", overwrite=True)
|
|
2553
|
+
"""
|
|
2554
|
+
|
|
2555
|
+
# If save_annotation is provided, save the annotation data
|
|
2556
|
+
if not isinstance(filename, str):
|
|
2557
|
+
raise ValueError("The annotation filename must be a string")
|
|
2558
|
+
|
|
2559
|
+
if not filename:
|
|
2560
|
+
raise ValueError("The annotation filename cannot be empty")
|
|
2561
|
+
|
|
2562
|
+
# Check if the annotation file exists
|
|
2563
|
+
if os.path.isfile(filename) and not overwrite:
|
|
2564
|
+
raise ValueError(
|
|
2565
|
+
f"Annotation file '{filename}' already exists. Please set overwrite to True or choose a different name."
|
|
2566
|
+
)
|
|
2567
|
+
|
|
2568
|
+
# Check if the directory exists
|
|
2569
|
+
annot_directory = os.path.dirname(filename)
|
|
2570
|
+
if annot_directory and not os.path.exists(annot_directory):
|
|
2571
|
+
raise FileNotFoundError(f"Directory '{annot_directory}' does not exist")
|
|
2572
|
+
|
|
2573
|
+
if not isinstance(parc_name, str):
|
|
2574
|
+
raise ValueError("parc_name must be a string")
|
|
2575
|
+
if not parc_name:
|
|
2576
|
+
raise ValueError("parc_name cannot be empty")
|
|
2577
|
+
|
|
2578
|
+
# Ensure the parc_name exists in the mesh point data
|
|
2579
|
+
if parc_name not in self.mesh.point_data:
|
|
2580
|
+
raise ValueError(f"Map '{parc_name}' not found in mesh point data")
|
|
2581
|
+
|
|
2582
|
+
# Extract the annotation data
|
|
2583
|
+
maps_array = self.mesh.point_data[parc_name]
|
|
2584
|
+
|
|
2585
|
+
# If there is a colortable for this map, use it
|
|
2586
|
+
if parc_name in self.colortables:
|
|
2587
|
+
ctable = self.colortables[parc_name]["color_table"]
|
|
2588
|
+
struct_names = self.colortables[parc_name]["struct_names"]
|
|
2589
|
+
|
|
2590
|
+
# Saving the annotation data in FreeSurfer format
|
|
2591
|
+
annot_obj = cltfree.AnnotParcellation()
|
|
2592
|
+
annot_obj.create_from_data(
|
|
2593
|
+
maps_array, ctable, struct_names, annot_id=parc_name
|
|
2594
|
+
)
|
|
2595
|
+
annot_obj.save_annotation(filename, force=overwrite)
|
|
2596
|
+
else:
|
|
2597
|
+
print(
|
|
2598
|
+
f"Warning: No colortable found for map '{parc_name}'. Annotation file will not be saved."
|
|
2599
|
+
)
|
|
2600
|
+
|
|
2601
|
+
###############################################################################################
|
|
2602
|
+
def plot(
|
|
2603
|
+
self,
|
|
2604
|
+
overlay_name: str = "surface",
|
|
2605
|
+
cmap: str = "viridis",
|
|
2606
|
+
vmin: np.float64 = None,
|
|
2607
|
+
vmax: np.float64 = None,
|
|
2608
|
+
views: Union[str, List[str]] = ["lateral"],
|
|
2609
|
+
hemi: str = "lh",
|
|
2610
|
+
notebook: bool = False,
|
|
2611
|
+
show_colorbar: bool = False,
|
|
2612
|
+
colorbar_title: str = None,
|
|
2613
|
+
colorbar_position: str = "bottom",
|
|
2614
|
+
save_path: str = None,
|
|
2615
|
+
):
|
|
2616
|
+
"""
|
|
2617
|
+
Plot the surface with specified overlay and visualization parameters.
|
|
2618
|
+
|
|
2619
|
+
Renders the surface mesh with optional overlays using PyVista, supporting
|
|
2620
|
+
multiple camera views, custom colormaps, and interactive or static output.
|
|
2621
|
+
Handles both categorical parcellation data and continuous scalar overlays.
|
|
2622
|
+
|
|
2623
|
+
Parameters
|
|
2624
|
+
----------
|
|
2625
|
+
overlay_name : str, default "surface"
|
|
2626
|
+
Name of the overlay to visualize from the surface's point data.
|
|
2627
|
+
|
|
2628
|
+
cmap : str, optional
|
|
2629
|
+
Colormap for scalar data. If None, uses parcellation colors for
|
|
2630
|
+
categorical data or 'viridis' for scalar data.
|
|
2631
|
+
|
|
2632
|
+
vmin : float, optional
|
|
2633
|
+
Minimum value for colormap scaling. If None, uses data minimum.
|
|
2634
|
+
|
|
2635
|
+
vmax : float, optional
|
|
2636
|
+
Maximum value for colormap scaling. If None, uses data maximum.
|
|
2637
|
+
|
|
2638
|
+
views : str or List[str], default ["lateral"]
|
|
2639
|
+
Camera view(s): 'lateral', 'medial', 'dorsal', 'ventral', 'anterior',
|
|
2640
|
+
'posterior', or multiple views like ['lateral', 'medial']. Also supports
|
|
2641
|
+
preset layouts: '4_views', '6_views', '8_views' with optional orientation.
|
|
2642
|
+
|
|
2643
|
+
hemi : str, default "lh"
|
|
2644
|
+
Hemisphere to visualize: 'lh' (left) or 'rh' (right).
|
|
2645
|
+
|
|
2646
|
+
notebook : bool, default False
|
|
2647
|
+
Whether to display in Jupyter notebook. If False, opens interactive window.
|
|
2648
|
+
|
|
2649
|
+
show_colorbar : bool, default False
|
|
2650
|
+
Whether to display colorbar. Automatically determined if None.
|
|
2651
|
+
|
|
2652
|
+
colorbar_title : str, optional
|
|
2653
|
+
Title for the colorbar. Uses overlay name if None.
|
|
2654
|
+
|
|
2655
|
+
colorbar_position : str, default "bottom"
|
|
2656
|
+
Colorbar position: 'bottom', 'top', 'left', or 'right'.
|
|
2657
|
+
|
|
2658
|
+
save_path : str, optional
|
|
2659
|
+
Path to save plot as image. If None, displays interactively.
|
|
2660
|
+
|
|
2661
|
+
Returns
|
|
2662
|
+
-------
|
|
2663
|
+
Plotter
|
|
2664
|
+
PyVista plotter object for further customization.
|
|
2665
|
+
|
|
2666
|
+
Raises
|
|
2667
|
+
------
|
|
2668
|
+
ValueError
|
|
2669
|
+
If overlay not found or invalid view parameter.
|
|
2670
|
+
|
|
2671
|
+
Examples
|
|
2672
|
+
--------
|
|
2673
|
+
>>> surface.plot(overlay_name="aparc")
|
|
2674
|
+
>>> surface.plot(overlay_name="thickness", cmap="hot", views="medial", show_colorbar=True)
|
|
2675
|
+
"""
|
|
2676
|
+
|
|
2677
|
+
# self.prepare_colors(overlay_name=overlay_name, cmap=cmap, vmin=vmin, vmax=vmax)
|
|
2678
|
+
|
|
2679
|
+
dict_ctables = self.colortables
|
|
2680
|
+
if cmap is None:
|
|
2681
|
+
if overlay_name in dict_ctables.keys():
|
|
2682
|
+
show_colorbar = False
|
|
2683
|
+
|
|
2684
|
+
else:
|
|
2685
|
+
show_colorbar = True
|
|
2686
|
+
|
|
2687
|
+
else:
|
|
2688
|
+
show_colorbar = True
|
|
2689
|
+
|
|
2690
|
+
from . import visualizationtools as cltvis
|
|
2691
|
+
|
|
2692
|
+
plotter = cltvis.SurfacePlotter()
|
|
2693
|
+
|
|
2694
|
+
plotter.plot_surfaces(
|
|
2695
|
+
self,
|
|
2696
|
+
hemi_id=hemi,
|
|
2697
|
+
views=views,
|
|
2698
|
+
map_names=overlay_name,
|
|
2699
|
+
colormaps=cmap,
|
|
2700
|
+
v_limits=(vmin, vmax),
|
|
2701
|
+
notebook=notebook,
|
|
2702
|
+
colorbar=show_colorbar,
|
|
2703
|
+
colorbar_titles=colorbar_title,
|
|
2704
|
+
colorbar_position=colorbar_position,
|
|
2705
|
+
save_path=save_path,
|
|
2706
|
+
)
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def merge_surfaces_list(surface_list):
|
|
2710
|
+
"""
|
|
2711
|
+
Merge a list of Surface objects into a single Surface object.
|
|
2712
|
+
|
|
2713
|
+
Combines multiple Surface objects by merging their geometries and point
|
|
2714
|
+
data into a unified surface representation. Preserves all overlays and
|
|
2715
|
+
associated data from the input surfaces.
|
|
2716
|
+
|
|
2717
|
+
Parameters
|
|
2718
|
+
----------
|
|
2719
|
+
surface_list : List[Surface]
|
|
2720
|
+
List of Surface objects to merge. Must contain at least one surface.
|
|
2721
|
+
|
|
2722
|
+
Returns
|
|
2723
|
+
-------
|
|
2724
|
+
Surface or None
|
|
2725
|
+
Merged Surface object containing combined geometries and data.
|
|
2726
|
+
Returns None if merging fails or list is empty.
|
|
2727
|
+
|
|
2728
|
+
Raises
|
|
2729
|
+
------
|
|
2730
|
+
TypeError
|
|
2731
|
+
If surface_list is not a list or contains non-Surface objects
|
|
2732
|
+
ValueError
|
|
2733
|
+
If the surface_list is empty
|
|
2734
|
+
|
|
2735
|
+
Notes
|
|
2736
|
+
-----
|
|
2737
|
+
The merging process combines vertex coordinates, face connectivity, and
|
|
2738
|
+
point data from all input surfaces. The first surface serves as the base,
|
|
2739
|
+
with subsequent surfaces appended to create a unified mesh.
|
|
2740
|
+
|
|
2741
|
+
Examples
|
|
2742
|
+
--------
|
|
2743
|
+
>>> surfaces = [surf1, surf2, surf3]
|
|
2744
|
+
>>> merged = merge_surfaces_list(surfaces)
|
|
2745
|
+
>>> print(f"Merged surface has {merged.mesh.n_points} vertices")
|
|
2746
|
+
"""
|
|
2747
|
+
|
|
2748
|
+
if not isinstance(surface_list, list):
|
|
2749
|
+
raise TypeError("surface_list must be a list")
|
|
2750
|
+
|
|
2751
|
+
if any(not isinstance(surf, Surface) for surf in surface_list):
|
|
2752
|
+
raise TypeError("All items in surface_list must be Surface objects")
|
|
2753
|
+
|
|
2754
|
+
# If the list is empty, return None
|
|
2755
|
+
if not surface_list:
|
|
2756
|
+
return None
|
|
2757
|
+
|
|
2758
|
+
# If there's only one surface, return it as is
|
|
2759
|
+
if len(surface_list) == 1:
|
|
2760
|
+
return copy.deepcopy(surface_list[0])
|
|
2761
|
+
|
|
2762
|
+
# Start with the first surface as the base for merging
|
|
2763
|
+
merged = copy.deepcopy(surface_list[0])
|
|
2764
|
+
|
|
2765
|
+
# Iterate through the rest of the surfaces and merge them
|
|
2766
|
+
for surf in surface_list[1:]:
|
|
2767
|
+
try:
|
|
2768
|
+
# Use the merge_surfaces method of the Surface class
|
|
2769
|
+
# This will handle the merging logic and return a new Surface object
|
|
2770
|
+
# If the merge_surfaces method modifies the merged object in place,
|
|
2771
|
+
# we can just continue using the merged object
|
|
2772
|
+
|
|
2773
|
+
# Most common: merge_surfaces returns a new object
|
|
2774
|
+
result = merged.merge_surfaces([surf])
|
|
2775
|
+
|
|
2776
|
+
# If result is not None, update merged
|
|
2777
|
+
if result is not None:
|
|
2778
|
+
merged = result
|
|
2779
|
+
|
|
2780
|
+
# If result is None, assume it modified merged in place
|
|
2781
|
+
except Exception as e:
|
|
2782
|
+
print(f"Merge failed: {e}")
|
|
2783
|
+
return None
|
|
2784
|
+
|
|
2785
|
+
return merged
|