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.
@@ -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