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,3239 @@
1
+ import os
2
+ from datetime import datetime
3
+ import copy
4
+ import h5py
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import nibabel as nib
9
+ import pyvista as pv
10
+ from pathlib import Path
11
+
12
+
13
+ from typing import Union, List, Optional
14
+ from scipy.ndimage import gaussian_filter
15
+ from skimage import measure
16
+
17
+ from rich.progress import (
18
+ Progress,
19
+ BarColumn,
20
+ TimeRemainingColumn,
21
+ TextColumn,
22
+ MofNCompleteColumn,
23
+ SpinnerColumn,
24
+ )
25
+
26
+ # Importing local modules
27
+ from . import misctools as cltmisc
28
+ from . import imagetools as cltimg
29
+ from . import segmentationtools as cltseg
30
+ from . import freesurfertools as cltfree
31
+ from . import surfacetools as cltsurf
32
+ from . import bidstools as cltbids
33
+
34
+
35
+ ####################################################################################################
36
+ ####################################################################################################
37
+ ############ ############
38
+ ############ ############
39
+ ############ Section 1: Class dedicated to work with parcellation images ############
40
+ ############ ############
41
+ ############ ############
42
+ ####################################################################################################
43
+ ####################################################################################################
44
+ class Parcellation:
45
+ """
46
+ Comprehensive class for working with brain parcellation data.
47
+
48
+ Provides tools for loading, manipulating, and analyzing brain parcellation
49
+ files with associated lookup tables. Supports filtering, masking, grouping,
50
+ volume calculations, and various export formats for neuroimaging workflows.
51
+ """
52
+
53
+ ####################################################################################################
54
+ def __init__(
55
+ self,
56
+ parc_file: Union[str, np.uint] = None,
57
+ affine: np.float64 = None,
58
+ parc_id: Optional[str] = None,
59
+ space_id: Optional[str] = "unknown",
60
+ ):
61
+ """
62
+ Initialize Parcellation object from file or array.
63
+
64
+ Parameters
65
+ ----------
66
+ parc_file : str or np.ndarray, optional
67
+ Path to parcellation file or numpy array. If string, loads from file
68
+ and attempts to find associated TSV/LUT files. Default is None.
69
+
70
+ affine : np.ndarray, optional
71
+ 4x4 affine transformation matrix. If None and parc_file is array,
72
+ creates identity matrix. Default is None.
73
+
74
+ parc_id : str, optional
75
+ Unique identifier for the parcellation. If None, generated from file name.
76
+ Default is None.
77
+
78
+ space_id : str, optional
79
+ Identifier for the space in which the parcellation is defined. Default is "unknown".
80
+
81
+ Attributes
82
+ ----------
83
+ data : np.ndarray
84
+ 3D parcellation data array.
85
+
86
+ affine : np.ndarray
87
+ 4x4 affine transformation matrix.
88
+
89
+ index : list
90
+ List of region codes present in parcellation.
91
+
92
+ name : list
93
+ List of region names corresponding to codes.
94
+
95
+ color : list
96
+ List of colors (hex format) for each region.
97
+
98
+ Examples
99
+ --------
100
+ >>> # Load from file
101
+ >>> parc = Parcellation('parcellation.nii.gz')
102
+ >>>
103
+ >>> # Create from array
104
+ >>> parc = Parcellation(label_array, affine=img.affine)
105
+ """
106
+
107
+ if parc_file is not None:
108
+ if isinstance(parc_file, str):
109
+ if os.path.exists(parc_file):
110
+ self.parc_file = parc_file
111
+ self.get_parcellation_id()
112
+ self.get_space_id(space_id=space_id)
113
+ temp_iparc = nib.load(parc_file)
114
+ affine = temp_iparc.affine
115
+ self.data = temp_iparc.get_fdata()
116
+ self.data.astype(np.int32)
117
+
118
+ self.affine = affine
119
+ self.dtype = temp_iparc.get_data_dtype()
120
+
121
+ if parc_file.endswith(".nii.gz"):
122
+ tsv_file = parc_file.replace(".nii.gz", ".tsv")
123
+ lut_file = parc_file.replace(".nii.gz", ".lut")
124
+
125
+ if os.path.isfile(tsv_file):
126
+ self.load_colortable(lut_file=tsv_file, lut_type="tsv")
127
+
128
+ elif not os.path.isfile(tsv_file) and os.path.isfile(lut_file):
129
+ self.load_colortable(lut_file=lut_file, lut_type="lut")
130
+
131
+ elif parc_file.endswith(".nii"):
132
+ tsv_file = parc_file.replace(".nii", ".tsv")
133
+ lut_file = parc_file.replace(".nii", ".lut")
134
+
135
+ if os.path.isfile(tsv_file):
136
+ self.load_colortable(lut_file=tsv_file, lut_type="tsv")
137
+
138
+ elif not os.path.isfile(tsv_file) and os.path.isfile(lut_file):
139
+ self.load_colortable(lut_file=lut_file, lut_type="lut")
140
+
141
+ # Adding index, name and color attributes
142
+ if not hasattr(self, "index"):
143
+ self.index = np.unique(self.data)
144
+ self.index = self.index[self.index != 0].tolist()
145
+ self.index = [int(x) for x in self.index]
146
+
147
+ if not hasattr(self, "name"):
148
+ # create a list with the names of the regions. I would like a format for the names similar to this supra-side-000001
149
+ self.name = cltmisc.create_names_from_indices(self.index)
150
+
151
+ if not hasattr(self, "color"):
152
+ self.color = cltmisc.create_random_colors(
153
+ len(self.index), output_format="hex"
154
+ )
155
+
156
+ else:
157
+ raise ValueError("The parcellation file does not exist")
158
+
159
+ # If the parcellation is a numpy array
160
+ elif isinstance(parc_file, np.ndarray):
161
+ self.parc_file = "numpy_array"
162
+
163
+ if parc_id is None:
164
+ self.id = "numpy_array"
165
+
166
+ self.space = space_id
167
+ self.data = parc_file
168
+
169
+ # Creating a new affine matrix if the affine matrix is None
170
+ if affine is None:
171
+ affine = np.eye(4)
172
+
173
+ center = np.array(self.data.shape) // 2
174
+ affine[:3, 3] = -center
175
+
176
+ self.affine = affine
177
+
178
+ # Create a list with all the values different from 0
179
+ st_codes = np.unique(self.data)
180
+ st_codes = st_codes[st_codes != 0]
181
+
182
+ self.index = st_codes.tolist()
183
+ self.index = [int(x) for x in self.index]
184
+ self.name = cltmisc.create_names_from_indices(self.index)
185
+
186
+ if len(self.index) > 0:
187
+ # Generate the colors
188
+ self.color = cltmisc.create_random_colors(
189
+ len(self.index), output_format="hex"
190
+ )
191
+ else:
192
+ self.color = []
193
+
194
+ # Adjust values to the ones present in the parcellation
195
+
196
+ # Force index to be int
197
+ if hasattr(self, "index"):
198
+ self.index = [int(x) for x in self.index]
199
+
200
+ if (
201
+ hasattr(self, "index")
202
+ and hasattr(self, "name")
203
+ and hasattr(self, "color")
204
+ ):
205
+ self.adjust_values()
206
+
207
+ # Dimensions of the parcellation
208
+ self.dim = self.data.shape
209
+
210
+ # Get voxel size
211
+ self.voxel_size = cltimg.get_voxel_volume(self.affine)
212
+
213
+ # Detect minimum and maximum labels
214
+ self.parc_range()
215
+
216
+ #####################################################################################################
217
+ def get_space_id(self, space_id: Optional[str] = "unknown"):
218
+ """
219
+ Set the space identifier for the parcellation.
220
+
221
+ Parameters
222
+ ----------
223
+ space_id : str, optional
224
+ Identifier for the space in which the parcellation is defined. Default is "unknown".
225
+
226
+ Raises
227
+ ------
228
+ ValueError
229
+ If the parcellation file is not set.
230
+
231
+ Returns
232
+ -------
233
+ space_id : str
234
+ The space identifier for the parcellation, formatted as 'space-<space_id>'.
235
+
236
+ Notes
237
+ -----
238
+ This method sets the `space` attribute of the Parcellation object.
239
+ It is useful for tracking the spatial context of the parcellation data.
240
+
241
+ Examples
242
+ --------
243
+ >>> parc = Parcellation('sub-01_ses-01_acq-mprage_space-t1_atlas-xxx_seg-yyy_scale-1_desc-test.nii.gz')
244
+ >>> space_id = parc.get_space_id()
245
+ >>> print(space_id)
246
+ 't1'
247
+
248
+ >>> parc = Parcellation('custom_parcellation.nii.gz')
249
+ >>> space_id = parc.get_space_id(space_id='custom_space')
250
+ >>> print(space_id)
251
+ 'custom_space'
252
+ >>> parc = Parcellation()
253
+ >>> space_id = parc.get_space_id()
254
+ 'unknown'
255
+
256
+ """
257
+
258
+ # Check if the parcellation file is set
259
+ if not hasattr(self, "parc_file"):
260
+ raise ValueError(
261
+ "The parcellation file is not set. Please load a parcellation file first."
262
+ )
263
+ # Get the base name of the parcellation file
264
+ parc_file_name = os.path.basename(self.parc_file)
265
+
266
+ # Check if the parcellation file name follows BIDS naming conventions
267
+ if cltbids.is_bids_filename(parc_file_name):
268
+
269
+ # Extract entities from the parcellation file name
270
+ name_ent_dict = cltbids.str2entity(parc_file_name)
271
+ ent_names_list = list(name_ent_dict.keys())
272
+
273
+ # Create space_id based on the entities present in the parcellation file name
274
+ if "space" in ent_names_list:
275
+ if space_id != "unknown":
276
+
277
+ # If space_id is provided, use it
278
+ space_id = name_ent_dict["space"]
279
+
280
+ # Assign the space_id to the object
281
+ self.space = space_id
282
+
283
+ return space_id
284
+
285
+ ####################################################################################################
286
+ def get_parcellation_id(self) -> str:
287
+ """
288
+ Generate a unique identifier for the parcellation based on its filename. If the filename
289
+ follows BIDS naming conventions, it extracts relevant entities to form the ID.
290
+ If the filename does not follow BIDS conventions, it uses the filename without extension.
291
+
292
+ Returns
293
+ -------
294
+ str
295
+ Unique identifier for the parcellation, formatted as 'atlas-<atlas_name>_seg-<seg_name>_scale-<scale_value>_desc-<description>'.
296
+ If no entities are found, it returns the filename without extension.
297
+
298
+ Raises
299
+ ------
300
+ ValueError
301
+ If the parcellation file is not set.
302
+
303
+ Notes
304
+ This method is useful for identifying and categorizing parcellation files based on their naming conventions.
305
+ It can be used to easily retrieve or reference specific parcellations in analyses or reports.
306
+
307
+ Examples
308
+ --------
309
+ >>> parc = Parcellation('sub-01_ses-01_acq-mprage_space-t1_atlas-xxx_seg-yyy_scale-1_desc-test.nii.gz')
310
+ >>> parc_id = parc.get_parcellation_id()
311
+ >>> print(parc_id)
312
+ 'atlas-xxx_seg-yyy_scale-1_desc-test'
313
+ >>> parc = Parcellation('custom_parcellation.nii.gz')
314
+ >>> parc_id = parc.get_parcellation_id()
315
+ >>> print(parc_id)
316
+ 'custom_parcellation'
317
+
318
+ """
319
+ # Check if the parcellation file is set
320
+ if not hasattr(self, "parc_file"):
321
+ raise ValueError(
322
+ "The parcellation file is not set. Please load a parcellation file first."
323
+ )
324
+
325
+ # Initialize parc_fullid as an empty string
326
+ parc_fullid = ""
327
+
328
+ # Get the base name of the parcellation file
329
+ parc_file_name = os.path.basename(self.parc_file)
330
+
331
+ # Check if the parcellation file name follows BIDS naming conventions
332
+ if cltbids.is_bids_filename(parc_file_name):
333
+
334
+ # Extract entities from the parcellation file name
335
+ name_ent_dict = cltbids.str2entity(parc_file_name)
336
+ ent_names_list = list(name_ent_dict.keys())
337
+
338
+ # Create parc_fullid based on the entities present in the parcellation file name
339
+ parc_fullid = ""
340
+ if "atlas" in ent_names_list:
341
+ parc_fullid = "atlas-" + name_ent_dict["atlas"]
342
+
343
+ if "seg" in ent_names_list:
344
+ parc_fullid += "_seg-" + name_ent_dict["seg"]
345
+
346
+ if "scale" in ent_names_list:
347
+ parc_fullid += "_scale-" + name_ent_dict["scale"]
348
+
349
+ if "desc" in ent_names_list:
350
+ parc_fullid += "_desc-" + name_ent_dict["desc"]
351
+
352
+ # Remove the _ if the parc_fullid starts with it
353
+ if parc_fullid.startswith("_"):
354
+ parc_fullid = parc_fullid[1:]
355
+
356
+ else:
357
+
358
+ # Remove the file extension if it exists
359
+ if parc_file_name.endswith(".nii.gz"):
360
+ parc_fullid = parc_file_name[:-7]
361
+ else:
362
+ parc_fullid = parc_file_name[:-4]
363
+
364
+ self.id = parc_fullid
365
+
366
+ return parc_fullid
367
+
368
+ ####################################################################################################
369
+ def export_summary_to_hdf5(self, out_file: str, overwrite: bool = False):
370
+ """
371
+ Export parcellation summary to HDF5 file.
372
+
373
+ Parameters
374
+ ----------
375
+ out_file : str
376
+ Path to output HDF5 file.
377
+
378
+ Raises
379
+ ------
380
+ ValueError
381
+ If the parcellation data is not set.
382
+
383
+ Notes
384
+ -----
385
+ This method saves the parcellation data, index, name, and color attributes to an HDF5 file.
386
+ It is useful for archiving and sharing parcellation information in a structured format.
387
+
388
+ Examples
389
+ --------
390
+ >>> parc.export_summary_to_hdf5('parcellation_summary.h5')
391
+ """
392
+
393
+ out_path = Path(out_file)
394
+
395
+ # Check if the output directory exists, if not create raise an error
396
+ if not out_path.parent.exists():
397
+ raise ValueError(
398
+ f"The output directory {out_path.parent} does not exist. Please create it first."
399
+ )
400
+
401
+ # Check if the output file already exists
402
+ if out_path.exists() and not overwrite:
403
+ raise ValueError(
404
+ f"The output file {out_path} already exists. Use overwrite=True to overwrite it."
405
+ )
406
+
407
+ # Check if the parcellation data is set
408
+ if not hasattr(self, "data"):
409
+ raise ValueError(
410
+ "The parcellation data is not set. Please load a parcellation file first."
411
+ )
412
+
413
+ # Check if the attributes parcellation_id and space_id are set
414
+ if not hasattr(self, "id"):
415
+ self.get_parcellation_id()
416
+
417
+ if not hasattr(self, "space"):
418
+ self.get_space_id(space_id="unknown")
419
+
420
+ parc_id = self.id
421
+ space_id = self.space
422
+ base_cad = f"parcellation_{parc_id}/space-{space_id}"
423
+
424
+ # Create the hf file
425
+ hf = h5py.File(out_file, "w")
426
+
427
+ # Save the filename
428
+ if hasattr(self, "parc_file"):
429
+ hf.create_dataset(f"{base_cad}/header/file_path", data=self.parc_file)
430
+
431
+ # Save the LUT file pathname if it exists
432
+ if hasattr(self, "lut_file"):
433
+ hf.create_dataset(f"{base_cad}/header/lut_file", data=self.lut_file)
434
+
435
+ # Save the parcellation id
436
+ if hasattr(self, "id"):
437
+ hf.create_dataset(f"{base_cad}/header/id", data=self.id)
438
+
439
+ # Save the space id
440
+ if hasattr(self, "space"):
441
+ hf.create_dataset(f"{base_cad}/header/space", data=self.space)
442
+
443
+ # Save the parcellation dimension
444
+ if hasattr(self, "dim"):
445
+ hf.create_dataset(f"{base_cad}/header/dim", data=self.dim)
446
+
447
+ # Save the parcellation voxel size
448
+ if hasattr(self, "voxel_size"):
449
+ hf.create_dataset(f"{base_cad}/header/voxel_size", data=self.voxel_size)
450
+
451
+ # Save the parcellation affine
452
+ if hasattr(self, "affine"):
453
+ hf.create_dataset(f"{base_cad}/header/affine", data=self.affine)
454
+
455
+ # Save the number of regions
456
+ if hasattr(self, "index"):
457
+ hf.create_dataset(f"{base_cad}/header/num_regions", data=len(self.index))
458
+
459
+ else:
460
+ # If index is not set, calculate the number of regions from the data
461
+ regions = np.unique(self.data)
462
+ n_regions = len(regions[regions != 0])
463
+ hf.create_dataset(f"{base_cad}/header/num_regions", data=n_regions)
464
+
465
+ # Save the minimum label
466
+ if hasattr(self, "min_label"):
467
+ hf.create_dataset(f"{base_cad}/header/min_label", data=self.min_label)
468
+ else:
469
+ # If min_label and max_label are not set, calculate them from the data
470
+ regions = np.unique(self.data)
471
+ regions = regions[regions != 0]
472
+ hf.create_dataset(f"{base_cad}/header/min_label", data=np.min(regions))
473
+
474
+ # Save the maximum label
475
+ if hasattr(self, "max_label"):
476
+ hf.create_dataset(f"{base_cad}/header/max_label", data=self.max_label)
477
+ else:
478
+ # If min_label and max_label are not set, calculate them from the data
479
+ regions = np.unique(self.data)
480
+ regions = regions[regions != 0]
481
+ hf.create_dataset(f"{base_cad}/header/max_label", data=np.max(regions))
482
+
483
+ # Save the index of the regions
484
+ if hasattr(self, "index"):
485
+ hf.create_dataset(f"{base_cad}/regions_indices", data=self.index)
486
+
487
+ # Save the region names
488
+ if hasattr(self, "name"):
489
+ hf.create_dataset(f"{base_cad}/regions_names", data=self.name)
490
+
491
+ # Save the region colors
492
+ if hasattr(self, "color"):
493
+ hf.create_dataset(f"{base_cad}/regions_colors", data=self.color)
494
+
495
+ # Save the parcellation centroids
496
+ if hasattr(self, "centroids"):
497
+ hf.create_dataset(f"{base_cad}/regions_centroids", data=self.centroids)
498
+
499
+ # Save the timeseries if they exist
500
+ if hasattr(self, "timeseries"):
501
+ hf.create_dataset(f"{base_cad}/time_series", data=self.timeseries)
502
+
503
+ # Close the file
504
+ hf.close()
505
+
506
+ # Save the morphometry DataFrame if it exists
507
+ if hasattr(self, "morphometry"):
508
+ cltmisc.save_morphometry_hdf5(
509
+ out_file, "{base_cad}/morphometry", self.morphometry, mode="w"
510
+ )
511
+
512
+ ####################################################################################################
513
+ def prepare_for_tracking(self):
514
+ """
515
+ Prepare parcellation for fiber tracking by merging cortical white matter labels
516
+ to their corresponding cortical gray matter values.
517
+
518
+ Converts white matter labels (>=3000) to corresponding gray matter labels
519
+ by subtracting 3000, and removes other structures labels (>=5000).
520
+
521
+ Examples
522
+ --------
523
+ >>> parc.prepare_for_tracking()
524
+ >>> print(f"Max label after prep: {parc.data.max()}")
525
+ """
526
+
527
+ # Unique of non-zero values
528
+ sts_vals = np.unique(self.data)
529
+
530
+ # sts_vals as integers
531
+ sts_vals = sts_vals.astype(int)
532
+
533
+ # get the values of sts_vals that are bigger or equaal to 5000 and create a list with them
534
+ indexes = [x for x in sts_vals if x >= 5000]
535
+
536
+ self.remove_by_code(codes2remove=indexes)
537
+
538
+ # Get the labeled wm values
539
+ ind = np.argwhere(self.data >= 3000)
540
+
541
+ # Add the wm voxels to the gm label
542
+ self.data[ind[:, 0], ind[:, 1], ind[:, 2]] = (
543
+ self.data[ind[:, 0], ind[:, 1], ind[:, 2]] - 3000
544
+ )
545
+
546
+ # Adjust the values
547
+ self.adjust_values()
548
+
549
+ ####################################################################################################
550
+ def keep_by_name(self, names2look: Union[list, str], rearrange: bool = False):
551
+ """
552
+ Filter parcellation to keep only regions with specified names.
553
+
554
+ Parameters
555
+ ----------
556
+ names2look : str or list
557
+ Name substring(s) to search for in region names.
558
+
559
+ rearrange : bool, optional
560
+ Whether to rearrange labels starting from 1. Default is False.
561
+
562
+ Examples
563
+ --------
564
+ >>> # Keep only hippocampal regions
565
+ >>> parc.keep_by_name('hippocampus')
566
+ >>>
567
+ >>> # Keep multiple regions and rearrange
568
+ >>> parc.keep_by_name(['frontal', 'parietal'], rearrange=True)
569
+ """
570
+
571
+ if isinstance(names2look, str):
572
+ names2look = [names2look]
573
+
574
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
575
+ # Find the indexes of the names that contain the substring
576
+ indexes = cltmisc.get_indexes_by_substring(
577
+ input_list=self.name, substr=names2look, invert=False, bool_case=False
578
+ )
579
+
580
+ if len(indexes) > 0:
581
+ sel_st_codes = [self.index[i] for i in indexes]
582
+ self.keep_by_code(codes2keep=sel_st_codes, rearrange=rearrange)
583
+ else:
584
+ print("The names were not found in the parcellation")
585
+
586
+ #####################################################################################################
587
+ def keep_by_code(
588
+ self, codes2keep: Union[list, np.ndarray], rearrange: bool = False
589
+ ):
590
+ """
591
+ Filter parcellation to keep only specified region codes.
592
+
593
+ Parameters
594
+ ----------
595
+ codes2keep : list or np.ndarray
596
+ Region codes to retain in parcellation.
597
+
598
+ rearrange : bool, optional
599
+ Whether to rearrange labels consecutively from 1. Default is False.
600
+
601
+ Raises
602
+ ------
603
+ ValueError
604
+ If codes2keep is empty or contains invalid codes.
605
+
606
+ Examples
607
+ --------
608
+ >>> # Keep specific regions
609
+ >>> parc.keep_by_code([1, 2, 5, 10])
610
+ >>>
611
+ >>> # Keep and rearrange
612
+ >>> parc.keep_by_code([100, 200, 300], rearrange=True)
613
+ """
614
+
615
+ # Convert the codes2keep to a numpy array
616
+ if isinstance(codes2keep, list):
617
+ codes2keep = cltmisc.build_indices(codes2keep)
618
+ codes2keep = np.array(codes2keep)
619
+
620
+ # Create a boolean mask where elements are True if they are in the retain list
621
+ mask = np.isin(self.data, codes2keep)
622
+
623
+ # Set elements to zero if they are not in the retain list
624
+ self.data[~mask] = 0
625
+
626
+ # Remove the elements from retain_list that are not present in the data
627
+ img_tmp_codes = np.unique(self.data)
628
+
629
+ # Codes to look is img_tmp_codes without the 0
630
+ codes2keep = img_tmp_codes[img_tmp_codes != 0]
631
+
632
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
633
+ sts = np.unique(self.data)
634
+ sts = sts[sts != 0]
635
+ temp_index = np.array(self.index)
636
+ mask = np.isin(temp_index, sts)
637
+ self.index = temp_index[mask].tolist()
638
+ self.name = np.array(self.name)[mask].tolist()
639
+ self.color = np.array(self.color)[mask].tolist()
640
+
641
+ # If rearrange is True, the parcellation will be rearranged starting from 1
642
+ if rearrange:
643
+ self.rearrange_parc()
644
+
645
+ # Detect minimum and maximum labels
646
+ self.parc_range()
647
+
648
+ #####################################################################################################
649
+ def remove_by_code(
650
+ self, codes2remove: Union[list, np.ndarray], rearrange: bool = False
651
+ ):
652
+ """
653
+ Remove regions with specified codes from parcellation.
654
+
655
+ Parameters
656
+ ----------
657
+ codes2remove : list or np.ndarray
658
+ Region codes to remove from parcellation.
659
+
660
+ rearrange : bool, optional
661
+ Whether to rearrange remaining labels from 1. Default is False.
662
+
663
+ Examples
664
+ --------
665
+ >>> # Remove specific regions
666
+ >>> parc.remove_by_code([1, 5, 10])
667
+ >>>
668
+ >>> # Remove and rearrange
669
+ >>> parc.remove_by_code([100, 200], rearrange=True)
670
+ """
671
+
672
+ if isinstance(codes2remove, list):
673
+ codes2remove = cltmisc.build_indices(codes2remove)
674
+ codes2remove = np.array(codes2remove)
675
+
676
+ self.data[np.isin(self.data, codes2remove)] = 0
677
+
678
+ st_codes = np.unique(self.data)
679
+ st_codes = st_codes[st_codes != 0]
680
+
681
+ # If rearrange is True, the parcellation will be rearranged starting from 1
682
+ if rearrange:
683
+ self.keep_by_code(codes2keep=st_codes, rearrange=True)
684
+ else:
685
+ self.keep_by_code(codes2keep=st_codes, rearrange=False)
686
+
687
+ # Detect minimum and maximum labels
688
+ self.parc_range()
689
+
690
+ #####################################################################################################
691
+ def remove_by_name(self, names2remove: Union[list, str], rearrange: bool = False):
692
+ """
693
+ Remove regions with specified names from parcellation.
694
+
695
+ Parameters
696
+ ----------
697
+ names2remove : str or list
698
+ Name substring(s) to search for removal.
699
+
700
+ rearrange : bool, optional
701
+ Whether to rearrange remaining labels from 1. Default is False.
702
+
703
+ Examples
704
+ --------
705
+ >>> # Remove ventricles
706
+ >>> parc.remove_by_name('ventricle')
707
+ >>>
708
+ >>> # Remove multiple structures
709
+ >>> parc.remove_by_name(['csf', 'unknown'], rearrange=True)
710
+ """
711
+
712
+ if isinstance(names2remove, str):
713
+ names2remove = [names2remove]
714
+
715
+ if hasattr(self, "name") and hasattr(self, "index") and hasattr(self, "color"):
716
+
717
+ indexes = cltmisc.get_indexes_by_substring(
718
+ input_list=self.name, substr=names2remove, invert=True, bool_case=False
719
+ )
720
+
721
+ if len(indexes) > 0:
722
+ sel_st_codes = [self.index[i] for i in indexes]
723
+ self.keep_by_code(codes2keep=sel_st_codes, rearrange=rearrange)
724
+
725
+ else:
726
+ print("The names were not found in the parcellation")
727
+ else:
728
+ print(
729
+ "The parcellation does not contain the attributes name, index and color"
730
+ )
731
+
732
+ # Detect minimum and maximum labels
733
+ self.parc_range()
734
+
735
+ #####################################################################################################
736
+ def apply_mask(
737
+ self,
738
+ image_mask,
739
+ codes2mask: Union[list, np.ndarray] = None,
740
+ mask_type: str = "upright",
741
+ fill: bool = False,
742
+ ):
743
+ """
744
+ Apply spatial mask to restrict parcellation to specific regions.
745
+
746
+ Parameters
747
+ ----------
748
+ image_mask : np.ndarray, Parcellation, or str
749
+ 3D mask array, parcellation object, or path to mask file.
750
+
751
+ codes2mask : list or np.ndarray, optional
752
+ Specific region codes to mask. If None, masks all regions. Default is None.
753
+
754
+ mask_type : str, optional
755
+ 'upright' to keep masked regions, 'inverted' to remove them. Default is 'upright'.
756
+
757
+ fill : bool, optional
758
+ Whether to grow regions to fill mask using region growing. Default is False.
759
+
760
+ Examples
761
+ --------
762
+ >>> # Apply cortical mask
763
+ >>> parc.apply_mask(cortex_mask, mask_type='upright')
764
+ >>>
765
+ >>> # Mask specific regions with filling
766
+ >>> parc.apply_mask(roi_mask, codes2mask=[1, 2, 3], fill=True)
767
+ """
768
+
769
+ if isinstance(image_mask, str):
770
+ if os.path.exists(image_mask):
771
+ temp_mask = nib.load(image_mask)
772
+ mask_data = temp_mask.get_fdata()
773
+ else:
774
+ raise ValueError("The mask file does not exist")
775
+
776
+ elif isinstance(image_mask, np.ndarray):
777
+ mask_data = image_mask
778
+
779
+ elif isinstance(image_mask, Parcellation):
780
+ mask_data = image_mask.data
781
+
782
+ mask_type.lower()
783
+ if mask_type not in ["upright", "inverted"]:
784
+ raise ValueError("The mask_type must be 'upright' or 'inverted'")
785
+
786
+ if codes2mask is None:
787
+ codes2mask = np.unique(self.data)
788
+ codes2mask = codes2mask[codes2mask != 0]
789
+
790
+ if isinstance(codes2mask, list):
791
+ codes2mask = cltmisc.build_indices(codes2mask)
792
+ codes2mask = np.array(codes2mask)
793
+
794
+ if mask_type == "inverted":
795
+ self.data[np.isin(mask_data, codes2mask) == True] = 0
796
+ bool_mask = np.isin(mask_data, codes2mask) == False
797
+
798
+ else:
799
+ self.data[np.isin(mask_data, codes2mask) == False] = 0
800
+ bool_mask = np.isin(mask_data, codes2mask) == True
801
+
802
+ if fill:
803
+
804
+ # Refilling the unlabeled voxels according to a supplied mask
805
+ self.data = cltseg.region_growing(self.data, bool_mask)
806
+
807
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
808
+ self.adjust_values()
809
+
810
+ # Detect minimum and maximum labels
811
+ self.parc_range()
812
+
813
+ def mask_image(
814
+ self,
815
+ image_2mask: Union[str, list, np.ndarray],
816
+ masked_image: Union[str, list, np.ndarray] = None,
817
+ codes2mask: Union[str, list, np.ndarray] = None,
818
+ mask_type: str = "upright",
819
+ ):
820
+ """
821
+ Mask external images using parcellation as binary mask.
822
+
823
+ Parameters
824
+ ----------
825
+ image_2mask : str, list, or np.ndarray
826
+ Image(s) to mask using parcellation.
827
+
828
+ masked_image : str or list, optional
829
+ Output path(s) for masked images. Default is None.
830
+
831
+ codes2mask : list or np.ndarray, optional
832
+ Region codes to use for masking. Default is None (all regions).
833
+
834
+ mask_type : str, optional
835
+ 'upright' uses specified codes, 'inverted' uses other codes. Default is 'upright'.
836
+
837
+ Examples
838
+ --------
839
+ >>> # Mask T1 image with parcellation
840
+ >>> parc.mask_image('T1w.nii.gz', 'T1w_masked.nii.gz')
841
+ >>>
842
+ >>> # Mask with specific regions
843
+ >>> parc.mask_image('fmri.nii.gz', codes2mask=[1, 2, 3])
844
+ """
845
+
846
+ if isinstance(image_2mask, str):
847
+ image_2mask = [image_2mask]
848
+
849
+ if isinstance(masked_image, str):
850
+ masked_image = [masked_image]
851
+
852
+ if isinstance(masked_image, list) and isinstance(image_2mask, list):
853
+ if len(masked_image) != len(image_2mask):
854
+ raise ValueError(
855
+ "The number of images to mask must be equal to the number of images to be saved"
856
+ )
857
+
858
+ if codes2mask is None:
859
+ # Get the indexes of all values different from zero
860
+ codes2mask = np.unique(self.data)
861
+ codes2mask = codes2mask[codes2mask != 0]
862
+
863
+ if isinstance(codes2mask, list):
864
+ codes2mask = cltmisc.build_indices(codes2mask)
865
+ codes2mask = np.array(codes2mask)
866
+
867
+ if mask_type == "inverted":
868
+ ind2rem = np.isin(self.data, codes2mask) == True
869
+
870
+ else:
871
+ ind2rem = np.isin(self.data, codes2mask) == False
872
+
873
+ if isinstance(image_2mask, list):
874
+ if isinstance(image_2mask[0], str):
875
+ for cont, img in enumerate(image_2mask):
876
+ if os.path.exists(img):
877
+ temp_img = nib.load(img)
878
+ img_data = temp_img.get_fdata()
879
+ img_data[ind2rem] = 0
880
+
881
+ # Save the masked image
882
+ out_img = nib.Nifti1Image(img_data, temp_img.affine)
883
+ nib.save(out_img, masked_image[cont])
884
+
885
+ else:
886
+ raise ValueError("The image file does not exist")
887
+ else:
888
+ raise ValueError(
889
+ "The image_2mask must be a list of strings containing the paths to the images"
890
+ )
891
+
892
+ elif isinstance(image_2mask, np.ndarray):
893
+ img_data = image_2mask
894
+ img_data[ind2rem] = 0
895
+
896
+ return img_data
897
+
898
+ ######################################################################################################
899
+ def compute_centroids(
900
+ self,
901
+ struct_codes: Union[List[int], np.ndarray] = None,
902
+ struct_names: Union[List[str], str] = None,
903
+ gaussian_smooth: bool = True,
904
+ sigma: float = 1.0,
905
+ closing_iterations: int = 2,
906
+ centroid_table: str = None,
907
+ ) -> pd.DataFrame:
908
+ """
909
+ Compute region centroids, voxel counts, and volumes.
910
+
911
+ Parameters
912
+ ----------
913
+ struct_codes : list or np.ndarray, optional
914
+ Specific region codes to include. Default is None (all regions).
915
+
916
+ struct_names : list or str, optional
917
+ Specific region names to include. Default is None.
918
+
919
+ gaussian_smooth : bool, optional
920
+ Whether to apply Gaussian smoothing to the volume before centroid calculation. Default is True.
921
+
922
+ sigma : float, optional
923
+ Standard deviation for Gaussian smoothing. Default is 1.0.
924
+
925
+ closing_iterations : int, optional
926
+ Number of morphological closing iterations before centroid extraction. Default is 2.
927
+
928
+ centroid_table : str, optional
929
+ Path to save results as TSV file. Default is None.
930
+
931
+ Returns
932
+ -------
933
+ pd.DataFrame
934
+ DataFrame with columns: index, name, color, X, Y, Z (mm), nvoxels, volume.
935
+
936
+ Raises
937
+ ------
938
+ ValueError
939
+ If both struct_codes and struct_names are specified.
940
+
941
+ Examples
942
+ --------
943
+ >>> # Compute all centroids
944
+ >>> centroids_df = parc.compute_centroids()
945
+ >>>
946
+ >>> # Specific regions with file output
947
+ >>> df = parc.compute_centroids(
948
+ ... struct_codes=[1, 2, 3],
949
+ ... centroid_table='centroids.tsv'
950
+ ... )
951
+ >>> # Specific regions by name
952
+ >>> df = parc.compute_centroids(
953
+ ... struct_names=['hippocampus', 'amygdala'],
954
+ ... centroid_table='centroids.tsv'
955
+ ... )
956
+ """
957
+
958
+ # Check if include_by_code and include_by_name are different from None at the same time
959
+ if struct_codes is not None and struct_names is not None:
960
+ raise ValueError(
961
+ "You cannot specify both include_by_code and include_by_name at the same time. Please choose one of them."
962
+ )
963
+
964
+ temp_parc = copy.deepcopy(self)
965
+
966
+ # Apply inclusion if specified
967
+ if struct_codes is not None:
968
+ temp_parc.keep_by_code(codes2keep=struct_codes)
969
+
970
+ if struct_names is not None:
971
+ temp_parc.keep_by_name(names2look=struct_names)
972
+
973
+ # Get unique region values
974
+ unique_regions = np.array(temp_parc.index)
975
+
976
+ # Lists to store results
977
+ codes = []
978
+ names = []
979
+ colors = []
980
+ x_coords = []
981
+ y_coords = []
982
+ z_coords = []
983
+ num_voxels = []
984
+ volumes = []
985
+
986
+ # Get voxel size
987
+ voxel_volume = cltimg.get_voxel_volume(temp_parc.affine)
988
+
989
+ # Fixed loop - iterate over regions and find their index
990
+ for region_label in unique_regions:
991
+ # Find the index of this region in parc.index
992
+ region_idx = np.where(np.array(temp_parc.index) == region_label)[0]
993
+ if len(region_idx) == 0:
994
+ continue
995
+ region_idx = region_idx[0] # Get the first (should be only) match
996
+
997
+ # Extract centroid and voxel count
998
+ centroid, voxel_count = cltimg.extract_centroid_from_volume(
999
+ temp_parc.data == region_label,
1000
+ gaussian_smooth=gaussian_smooth,
1001
+ sigma=sigma,
1002
+ closing_iterations=closing_iterations,
1003
+ )
1004
+
1005
+ centroid_x, centroid_y, centroid_z = centroid[0], centroid[1], centroid[2]
1006
+
1007
+ # Calculate total volume
1008
+ total_volume = voxel_count * voxel_volume
1009
+
1010
+ # Store results
1011
+ codes.append(int(region_label))
1012
+ names.append(temp_parc.name[region_idx])
1013
+ colors.append(temp_parc.color[region_idx])
1014
+ x_coords.append(centroid_x)
1015
+ y_coords.append(centroid_y)
1016
+ z_coords.append(centroid_z)
1017
+ num_voxels.append(voxel_count)
1018
+ volumes.append(total_volume)
1019
+
1020
+ # Convert coordinates to mm
1021
+ coords_vox = np.stack(
1022
+ (np.array(x_coords), np.array(y_coords), np.array(z_coords)), axis=-1
1023
+ )
1024
+ coords_mm = cltimg.vox2mm(coords_vox, self.affine)
1025
+
1026
+ # Add centroid coordinates in mm
1027
+ self.centroids = coords_mm.astype(float)
1028
+
1029
+ x_coords_mm = coords_mm[:, 0]
1030
+ y_coords_mm = coords_mm[:, 1]
1031
+ z_coords_mm = coords_mm[:, 2]
1032
+
1033
+ # Convert to list
1034
+ x_coords_mm = x_coords_mm.tolist()
1035
+ y_coords_mm = y_coords_mm.tolist()
1036
+ z_coords_mm = z_coords_mm.tolist()
1037
+
1038
+ # Create DataFrame
1039
+ df = pd.DataFrame(
1040
+ {
1041
+ "index": codes,
1042
+ "name": names,
1043
+ "color": colors,
1044
+ "Xvox": x_coords,
1045
+ "Yvox": y_coords,
1046
+ "Zvox": z_coords,
1047
+ "Xmm": x_coords_mm,
1048
+ "Ymm": y_coords_mm,
1049
+ "Zmm": z_coords_mm,
1050
+ "nvoxels": num_voxels,
1051
+ "volume": volumes,
1052
+ }
1053
+ )
1054
+
1055
+ # Save to TSV file if path is provided
1056
+ if centroid_table is not None:
1057
+ try:
1058
+ # Check if the directory exists
1059
+ directory = os.path.dirname(centroid_table)
1060
+ if directory and not os.path.exists(directory):
1061
+ print(
1062
+ f"Warning: Directory '{directory}' does not exist. Cannot save file."
1063
+ )
1064
+ else:
1065
+ # Save as TSV file
1066
+ df.to_csv(centroid_table, sep="\t", index=False)
1067
+ print(f"Centroid table saved to: {centroid_table}")
1068
+ except Exception as e:
1069
+ print(f"Error saving centroid table: {e}")
1070
+
1071
+ return df
1072
+
1073
+ ######################################################################################################
1074
+ def get_regionwise_timeseries(
1075
+ self,
1076
+ time_series_data: Union[str, np.ndarray],
1077
+ vols_to_delete: Union[List[int], np.ndarray] = None,
1078
+ method: str = "nilearn",
1079
+ metric: str = "mean",
1080
+ struct_codes: Union[List[int], np.ndarray] = None,
1081
+ struct_names: Union[List[str], str] = None,
1082
+ ) -> pd.DataFrame:
1083
+ """
1084
+ Compute region-wise time series.
1085
+
1086
+ Parameters
1087
+ ----------
1088
+ time_series_data : str or np.ndarray
1089
+ Path to time series file or numpy array with shape (dimx X dimy X dimZ x Timepoints).
1090
+
1091
+ struct_codes : list or np.ndarray, optional
1092
+ Specific region codes to include. Default is None (all regions).
1093
+
1094
+ struct_names : list or str, optional
1095
+ Specific region names to include. Default is None.
1096
+
1097
+ ouput_h5file : str, optional
1098
+ Path to save results as HDF5 file. Default is None.
1099
+
1100
+ Returns
1101
+ -------
1102
+ pd.DataFrame
1103
+ DataFrame with columns: index, name, color, X, Y, Z (mm), nvoxels, volume.
1104
+
1105
+ Raises
1106
+ ------
1107
+ ValueError
1108
+ If both struct_codes and struct_names are specified.
1109
+
1110
+ Examples
1111
+ --------
1112
+ >>> # Compute region-wise time series from file
1113
+ >>> region_ts = parc.get_regionwise_timeseries('timeseries.nii.gz')
1114
+ >>>
1115
+ # Compute from numpy array
1116
+ >>> region_ts = parc.get_regionwise_timeseries(time_series_data=np.random.rand(64, 64, 64, 100))
1117
+ >>>
1118
+ # Compute with specific regions using codes
1119
+ >>> region_ts = parc.get_regionwise_timeseries(
1120
+ ... time_series_data='timeseries.nii.gz',
1121
+ ... struct_codes=[1, 2, 3])
1122
+
1123
+ """
1124
+
1125
+ # Check if include_by_code and include_by_name are different from None at the same time
1126
+ if struct_codes is not None and struct_names is not None:
1127
+ raise ValueError(
1128
+ "You cannot specify both include_by_code and include_by_name at the same time. Please choose one of them."
1129
+ )
1130
+
1131
+ temp_parc = copy.deepcopy(self)
1132
+
1133
+ # Apply inclusion if specified
1134
+ if struct_codes is not None:
1135
+ temp_parc.keep_by_code(codes2keep=struct_codes)
1136
+
1137
+ if struct_names is not None:
1138
+ temp_parc.keep_by_name(names2look=struct_names)
1139
+
1140
+ # Delete volumes if specified
1141
+ if vols_to_delete is not None:
1142
+
1143
+ # Check if the time_series_data is a string
1144
+ if isinstance(time_series_data, str):
1145
+ # Generating a temporary file to save the 4D data
1146
+ tmp_image = cltmisc.create_temporary_filename(
1147
+ prefix="temp_timeseries",
1148
+ extension=".nii.gz",
1149
+ tmp_dir="/tmp",
1150
+ )
1151
+
1152
+ # Deleting the volumes from the 4D image
1153
+ del_img = cltimg.delete_volumes_from_4D_images(
1154
+ in_image=time_series_data,
1155
+ out_image=tmp_image,
1156
+ vols_to_delete=vols_to_delete,
1157
+ )
1158
+ time_series_data_tmp = tmp_image
1159
+
1160
+ elif isinstance(time_series_data, np.ndarray):
1161
+ time_series_data_tmp, _ = cltimg.delete_volumes_from_4D_array(
1162
+ in_array=time_series_data, vols_to_delete=vols_to_delete
1163
+ )
1164
+ else:
1165
+ time_series_data_tmp = time_series_data
1166
+
1167
+ if method == "nilearn":
1168
+
1169
+ if isinstance(time_series_data_tmp, str):
1170
+
1171
+ # Check if the file exists
1172
+ try:
1173
+ from nilearn.maskers import NiftiLabelsMasker
1174
+ except:
1175
+ raise ImportError(
1176
+ "nilearn is not installed. Please install it to use this method."
1177
+ )
1178
+
1179
+ # Generating a temporary parcellation file
1180
+ tmp_parc_image = cltmisc.create_temporary_filename(
1181
+ prefix="temp_parcellation", extension=".nii.gz", tmp_dir="/tmp"
1182
+ )
1183
+
1184
+ temp_parc.save_parcellation(out_file=tmp_parc_image, save_lut=True)
1185
+ tmp_parc_image_lut = tmp_parc_image.replace(".nii.gz", ".lut")
1186
+ tmp_parc_image_nilearnlut = tmp_parc_image.replace(
1187
+ ".nii.gz", "_nilearn.lut"
1188
+ )
1189
+
1190
+ # Converting the parcellation to a nilearn LUT format
1191
+ temp_parc.lut_to_nilearnlut(
1192
+ tmp_parc_image_lut, tmp_parc_image_nilearnlut, overwrite=True
1193
+ )
1194
+
1195
+ # Generating the masker
1196
+ masker = NiftiLabelsMasker(
1197
+ labels_img=tmp_parc_image,
1198
+ lut=tmp_parc_image_nilearnlut,
1199
+ standardize="zscore_sample",
1200
+ standardize_confounds=True,
1201
+ memory="nilearn_cache",
1202
+ verbose=1,
1203
+ )
1204
+
1205
+ # Check if the parcellation is a numpy array
1206
+ region_time_series = masker.fit_transform(time_series_data_tmp).T
1207
+
1208
+ # Delete the temporary files
1209
+ os.remove(tmp_parc_image)
1210
+ os.remove(tmp_parc_image_lut)
1211
+ os.remove(tmp_parc_image_nilearnlut)
1212
+
1213
+ elif isinstance(time_series_data_tmp, np.ndarray):
1214
+ print(
1215
+ "Using nilearn method requires a file path. Please provide a valid file path."
1216
+ )
1217
+ print(
1218
+ "Computing region-wise timeseries without using nilearn. This may take longer."
1219
+ )
1220
+ method = "clabtoolkit"
1221
+
1222
+ if method.lower() != "nilearn":
1223
+
1224
+ # Get unique region values
1225
+ unique_regions = np.array(temp_parc.index)
1226
+
1227
+ # Load time series data
1228
+ if isinstance(time_series_data_tmp, str):
1229
+ if os.path.exists(time_series_data_tmp):
1230
+ time_series = nib.load(time_series_data_tmp).get_fdata()
1231
+
1232
+ else:
1233
+ raise ValueError("The time series file does not exist")
1234
+
1235
+ elif isinstance(time_series_data, np.ndarray):
1236
+ time_series = time_series_data
1237
+
1238
+ else:
1239
+ raise ValueError(
1240
+ "time_series_data must be a string (file path) or a numpy array"
1241
+ )
1242
+
1243
+ # Check if time series has 4 dimensions
1244
+ if time_series.ndim != 4:
1245
+ raise ValueError(
1246
+ "Time series data must have 4 dimensions (dimx, dimy, dimz, timepoints)"
1247
+ )
1248
+ # Check if time series dimensions match parcellation dimensions
1249
+ if time_series.shape[:3] != temp_parc.data.shape:
1250
+ raise ValueError(
1251
+ "Time series dimensions do not match parcellation dimensions"
1252
+ )
1253
+
1254
+ # Detect the number of time points
1255
+ num_timepoints = time_series.shape[-1]
1256
+
1257
+ # Initialize array to hold region-wise time series
1258
+ region_time_series = np.zeros((len(unique_regions), num_timepoints))
1259
+
1260
+ # Fixed loop - iterate over regions and find their index
1261
+ for i, region_label in enumerate(unique_regions):
1262
+ # Find the index of this region in parc.index
1263
+ region_idx = np.where(np.array(temp_parc.index) == region_label)[0]
1264
+ if len(region_idx) == 0:
1265
+ continue
1266
+ region_idx = region_idx[0] # Get the first (should be only) match
1267
+
1268
+ # Computing the mean time series at non-zero voxels for this region
1269
+ ts_values = cltimg.compute_statistics_at_nonzero_voxels(
1270
+ temp_parc.data == region_label, time_series, metric=metric
1271
+ )
1272
+
1273
+ region_time_series[i, :] = ts_values
1274
+
1275
+ # Create an attribute to hold the time series
1276
+ temp_parc.seriesextractionmethod = method
1277
+ temp_parc.timeseries = region_time_series
1278
+
1279
+ return region_time_series
1280
+
1281
+ ######################################################################################################
1282
+ def surface_extraction(
1283
+ self,
1284
+ struct_codes: Union[List[int], np.ndarray] = None,
1285
+ struct_names: Union[List[str], str] = None,
1286
+ gaussian_smooth: bool = True,
1287
+ smooth_iterations: int = 10,
1288
+ fill_holes: bool = True,
1289
+ sigma: float = 1.0,
1290
+ closing_iterations: int = 1,
1291
+ out_filename: str = None,
1292
+ out_format: str = "freesurfer",
1293
+ save_annotation: bool = True,
1294
+ overwrite: bool = False,
1295
+ ):
1296
+ """
1297
+ Extract 3D surface meshes from parcellation regions.
1298
+
1299
+ Uses marching cubes algorithm with optional smoothing and hole filling
1300
+ to create high-quality surface meshes for visualization or analysis.
1301
+
1302
+ Parameters
1303
+ ----------
1304
+ struct_codes : list or np.ndarray, optional
1305
+ Region codes to extract surfaces for. Default is None (all regions).
1306
+
1307
+ struct_names : list or str, optional
1308
+ Region names to extract surfaces for. Default is None.
1309
+
1310
+ gaussian_smooth : bool, optional
1311
+ Whether to apply Gaussian smoothing to volume. Default is True.
1312
+
1313
+ smooth_iterations : int, optional
1314
+ Number of Taubin smoothing iterations. Default is 10.
1315
+
1316
+ fill_holes : bool, optional
1317
+ Whether to fill holes in extracted meshes. Default is True.
1318
+
1319
+ sigma : float, optional
1320
+ Standard deviation for Gaussian smoothing. Default is 1.0.
1321
+
1322
+ closing_iterations : int, optional
1323
+ Morphological closing iterations before extraction. Default is 1.
1324
+
1325
+ out_filename : str, optional
1326
+ Output file path for merged surface. Default is None.
1327
+
1328
+ out_format : str, optional
1329
+ Output format: 'freesurfer', 'vtk', 'ply', 'stl', 'obj'. Default is 'freesurfer'.
1330
+
1331
+ save_annotation : bool, optional
1332
+ Whether to save annotation file with surface. Default is True.
1333
+
1334
+ overwrite : bool, optional
1335
+ Whether to overwrite existing files. Default is False.
1336
+
1337
+ Returns
1338
+ -------
1339
+ Surface
1340
+ Merged surface object containing all extracted regions.
1341
+
1342
+ Raises
1343
+ ------
1344
+ ValueError
1345
+ If both struct_codes and struct_names are specified.
1346
+ FileNotFoundError
1347
+ If output directory doesn't exist.
1348
+ FileExistsError
1349
+ If output file exists and overwrite=False.
1350
+
1351
+ Examples
1352
+ --------
1353
+ >>> # Extract all surfaces
1354
+ >>> surface = parc.surface_extraction()
1355
+ >>>
1356
+ >>> # Extract specific regions with high quality
1357
+ >>> surface = parc.surface_extraction(
1358
+ ... struct_codes=[1, 2, 3],
1359
+ ... smooth_iterations=20,
1360
+ ... out_filename='regions.surf'
1361
+ ... )
1362
+ """
1363
+
1364
+ # Check if include_by_code and include_by_name are different from None at the same time
1365
+ if struct_codes is not None and struct_names is not None:
1366
+ raise ValueError(
1367
+ "You cannot specify both include_by_code and include_by_name at the same time. Please choose one of them."
1368
+ )
1369
+
1370
+ temp_parc = copy.deepcopy(self)
1371
+
1372
+ # Apply inclusion if specified
1373
+ if struct_codes is not None:
1374
+ temp_parc.keep_by_code(codes2keep=struct_codes)
1375
+
1376
+ if struct_names is not None:
1377
+ temp_parc.keep_by_name(names2look=struct_names)
1378
+
1379
+ # Get unique region values
1380
+ unique_regions = np.array(temp_parc.index)
1381
+
1382
+ color_table = cltfree.colors2colortable(temp_parc.color)
1383
+ color_table, log, corresp_dict = cltfree.resolve_colortable_duplicates(
1384
+ color_table
1385
+ )
1386
+
1387
+ table_dict = {
1388
+ "struct_names": temp_parc.name,
1389
+ "color_table": color_table,
1390
+ "lookup_table": None,
1391
+ }
1392
+ color_tables = {
1393
+ "surface": table_dict,
1394
+ }
1395
+
1396
+ surfaces_list = []
1397
+
1398
+ # Add Rich progress bar around the main loop
1399
+ with Progress(
1400
+ SpinnerColumn(),
1401
+ TextColumn("[bold blue]{task.description}", justify="right"),
1402
+ BarColumn(bar_width=None),
1403
+ MofNCompleteColumn(),
1404
+ TextColumn("•"),
1405
+ TimeRemainingColumn(),
1406
+ expand=True,
1407
+ ) as progress:
1408
+
1409
+ task = progress.add_task("Mesh extraction", total=len(unique_regions))
1410
+
1411
+ for i, code in enumerate(unique_regions):
1412
+ struct_name = temp_parc.name[i]
1413
+ progress.update(
1414
+ task,
1415
+ description=f"Mesh extraction (Code {code}: {struct_name})",
1416
+ completed=i + 1,
1417
+ )
1418
+
1419
+ # Create binary mask for current code
1420
+ st_parc_temp = copy.deepcopy(self)
1421
+ st_parc_temp.keep_by_code(codes2keep=[code], rearrange=True)
1422
+
1423
+ mesh = cltimg.extract_mesh_from_volume(
1424
+ st_parc_temp.data,
1425
+ gaussian_smooth=gaussian_smooth,
1426
+ sigma=sigma,
1427
+ fill_holes=fill_holes,
1428
+ smooth_iterations=smooth_iterations,
1429
+ affine=st_parc_temp.affine,
1430
+ closing_iterations=closing_iterations,
1431
+ vertex_value=color_table[i, 4],
1432
+ )
1433
+
1434
+ surf_temp = cltsurf.Surface()
1435
+ surf_temp.mesh = copy.deepcopy(mesh)
1436
+ # surf_temp.load_from_mesh(mesh, hemi="lh")
1437
+ surfaces_list.append(surf_temp)
1438
+ # Update progress to show completion of this region
1439
+
1440
+ # surf_orig.merge_surfaces(surfaces_list)
1441
+ merged_surf = cltsurf.merge_surfaces_list(surfaces_list)
1442
+ merged_surf.colortables = color_tables
1443
+
1444
+ if out_filename is not None:
1445
+ # Check if the directory exists, if not, gives an error
1446
+ path_dir = os.path.dirname(out_filename)
1447
+
1448
+ if not os.path.exists(path_dir):
1449
+ raise FileNotFoundError(
1450
+ f"The directory {path_dir} does not exist. Please create it before saving the surface."
1451
+ )
1452
+
1453
+ # Check if the file exists, if it does check if overwrite is True
1454
+ if os.path.exists(out_filename) and not overwrite:
1455
+ raise FileExistsError(
1456
+ f"The file {out_filename} already exists. Please set overwrite=True to overwrite it."
1457
+ )
1458
+
1459
+ if save_annotation:
1460
+ save_path = os.path.dirname(out_filename)
1461
+ save_name = os.path.basename(out_filename)
1462
+
1463
+ # Replace the file extension with .annot
1464
+ save_name = os.path.splitext(save_name)[0] + ".annot"
1465
+ annot_filename = os.path.join(save_path, save_name)
1466
+
1467
+ merged_surf.save_surface(
1468
+ filename=out_filename,
1469
+ format=out_format,
1470
+ map_name="surface",
1471
+ save_annotation=annot_filename,
1472
+ overwrite=overwrite,
1473
+ )
1474
+ else:
1475
+ merged_surf.save_surface(
1476
+ filename=out_filename,
1477
+ format=out_format,
1478
+ map_name="surface",
1479
+ overwrite=overwrite,
1480
+ )
1481
+
1482
+ return merged_surf
1483
+
1484
+ ######################################################################################################
1485
+ def adjust_values(self):
1486
+ """
1487
+ Synchronize index, name, and color attributes with data contents.
1488
+
1489
+ Removes entries for codes not present in data and updates
1490
+ min/max label range.
1491
+
1492
+ Examples
1493
+ --------
1494
+ >>> parc.adjust_values()
1495
+ >>> print(f"Regions in data: {len(parc.index)}")
1496
+ """
1497
+
1498
+ st_codes = np.unique(self.data)
1499
+ unique_codes = st_codes[st_codes != 0]
1500
+
1501
+ mask = np.isin(self.index, unique_codes)
1502
+ indexes = np.where(mask)[0]
1503
+
1504
+ temp_index = np.array(self.index)
1505
+ index_new = temp_index[mask]
1506
+
1507
+ if hasattr(self, "index"):
1508
+ self.index = [int(x) for x in index_new.tolist()]
1509
+
1510
+ # If name is an attribute of self
1511
+ if hasattr(self, "name"):
1512
+ self.name = [self.name[i] for i in indexes]
1513
+
1514
+ # If color is an attribute of self
1515
+ if hasattr(self, "color"):
1516
+ self.color = [self.color[i] for i in indexes]
1517
+
1518
+ self.parc_range()
1519
+
1520
+ ######################################################################################################
1521
+ def group_by_code(
1522
+ self,
1523
+ codes2group: Union[list, np.ndarray],
1524
+ new_codes: Union[list, np.ndarray] = None,
1525
+ new_names: Union[list, str] = None,
1526
+ new_colors: Union[list, np.ndarray] = None,
1527
+ ):
1528
+ """
1529
+ Group regions by combining specified codes into new regions.
1530
+
1531
+ Parameters
1532
+ ----------
1533
+ codes2group : list or np.ndarray
1534
+ List of codes or list of code lists to group.
1535
+
1536
+ new_codes : list or np.ndarray, optional
1537
+ New codes for groups. If None, uses sequential numbering. Default is None.
1538
+
1539
+ new_names : list or str, optional
1540
+ New names for groups. Default is None.
1541
+
1542
+ new_colors : list or np.ndarray, optional
1543
+ New colors for groups. Default is None.
1544
+
1545
+ Examples
1546
+ --------
1547
+ >>> # Group bilateral regions
1548
+ >>> parc.group_by_code(
1549
+ ... [[1, 2], [3, 4]], # Left/right pairs
1550
+ ... new_names=['region1', 'region2']
1551
+ ... )
1552
+ """
1553
+
1554
+ # if all the elements in codes2group are numeric then convert codes2group to a numpy array
1555
+ if all(isinstance(x, (int, np.integer, float)) for x in codes2group):
1556
+ codes2group = np.array(codes2group)
1557
+
1558
+ # Detect thecodes2group is a list of list
1559
+ if isinstance(codes2group, list):
1560
+ if isinstance(codes2group[0], list):
1561
+ n_groups = len(codes2group)
1562
+
1563
+ elif isinstance(codes2group[0], (str, np.integer, int, tuple)):
1564
+ codes2group = [codes2group]
1565
+ n_groups = 1
1566
+
1567
+ elif isinstance(codes2group, np.ndarray):
1568
+ codes2group = [codes2group.tolist()]
1569
+ n_groups = 1
1570
+
1571
+ for i, v in enumerate(codes2group):
1572
+ if isinstance(v, list):
1573
+ codes2group[i] = cltmisc.build_indices(v)
1574
+
1575
+ # Convert the new_codes to a numpy array
1576
+ if new_codes is not None:
1577
+ if isinstance(new_codes, list):
1578
+ new_codes = cltmisc.build_indices(new_codes)
1579
+ new_codes = np.array(new_codes)
1580
+ elif isinstance(new_codes, (str, np.integer, int)):
1581
+ new_codes = np.array([new_codes])
1582
+
1583
+ else:
1584
+ new_codes = np.arange(1, n_groups + 1)
1585
+
1586
+ if len(new_codes) != n_groups:
1587
+ raise ValueError(
1588
+ "The number of new codes must be equal to the number of groups that will be created"
1589
+ )
1590
+
1591
+ # Convert the new_names to a list
1592
+ if new_names is not None:
1593
+ if isinstance(new_names, str):
1594
+ new_names = [new_names]
1595
+
1596
+ if len(new_names) != n_groups:
1597
+ raise ValueError(
1598
+ "The number of new names must be equal to the number of groups that will be created"
1599
+ )
1600
+
1601
+ # Convert the new_colors to a numpy array
1602
+ if new_colors is not None:
1603
+ if isinstance(new_colors, list):
1604
+
1605
+ if isinstance(new_colors[0], str):
1606
+ new_colors = cltmisc.multi_hex2rgb(new_colors)
1607
+
1608
+ elif isinstance(new_colors[0], np.ndarray):
1609
+ new_colors = np.array(new_colors)
1610
+
1611
+ else:
1612
+ raise ValueError(
1613
+ "If new_colors is a list, it must be a list of hexadecimal colors or a list of rgb colors"
1614
+ )
1615
+
1616
+ elif isinstance(new_colors, np.ndarray):
1617
+ pass
1618
+
1619
+ else:
1620
+ raise ValueError(
1621
+ "The new_colors must be a list of colors or a numpy array"
1622
+ )
1623
+
1624
+ new_colors = cltmisc.readjust_colors(new_colors)
1625
+
1626
+ if new_colors.shape[0] != n_groups:
1627
+ raise ValueError(
1628
+ "The number of new colors must be equal to the number of groups that will be created"
1629
+ )
1630
+
1631
+ # Creating the grouped parcellation
1632
+ out_atlas = np.zeros_like(self.data, dtype="int16")
1633
+ for i in range(n_groups):
1634
+ code2look = np.array(codes2group[i])
1635
+
1636
+ if new_codes is not None:
1637
+ out_atlas[np.isin(self.data, code2look) == True] = new_codes[i]
1638
+ else:
1639
+ out_atlas[np.isin(self.data, code2look) == True] = i + 1
1640
+
1641
+ self.data = out_atlas
1642
+
1643
+ if new_codes is not None:
1644
+ self.index = new_codes.tolist()
1645
+
1646
+ if new_names is not None:
1647
+ self.name = new_names
1648
+ else:
1649
+ # If new_names is not provided, the names will be created
1650
+ self.name = ["group_{}".format(i) for i in new_codes]
1651
+
1652
+ if new_colors is not None:
1653
+ self.color = new_colors
1654
+ else:
1655
+ # If new_colors is not provided, the colors will be created
1656
+ self.color = cltmisc.create_random_colors(n_groups)
1657
+
1658
+ # Detect minimum and maximum labels
1659
+ self.parc_range()
1660
+
1661
+ ######################################################################################################
1662
+ def group_by_name(
1663
+ self,
1664
+ names2group: Union[List[list], List[str]],
1665
+ new_codes: Union[list, np.ndarray] = None,
1666
+ new_names: Union[list, str] = None,
1667
+ new_colors: Union[list, np.ndarray] = None,
1668
+ ):
1669
+ """
1670
+ Group regions by combining regions with specified name patterns.
1671
+
1672
+ Parameters
1673
+ ----------
1674
+ names2group : list
1675
+ List of name patterns or list of pattern lists to group.
1676
+
1677
+ new_codes : list or np.ndarray, optional
1678
+ New codes for groups. Default is None.
1679
+
1680
+ new_names : list or str, optional
1681
+ New names for groups. Default is None.
1682
+
1683
+ new_colors : list or np.ndarray, optional
1684
+ New colors for groups. Default is None.
1685
+
1686
+ Examples
1687
+ --------
1688
+ >>> # Group by anatomical regions
1689
+ >>> parc.group_by_name(
1690
+ ... [['frontal'], ['parietal'], ['temporal']],
1691
+ ... new_names=['frontal_lobe', 'parietal_lobe', 'temporal_lobe']
1692
+ ... )
1693
+ """
1694
+
1695
+ # Detect thecodes2group is a list of list
1696
+ if isinstance(names2group, list):
1697
+ if isinstance(names2group[0], list):
1698
+ n_groups = len(names2group)
1699
+
1700
+ elif isinstance(codes2group[0], (str)):
1701
+ codes2group = [codes2group]
1702
+ n_groups = 1
1703
+
1704
+ for i, v in enumerate(codes2group):
1705
+ if isinstance(v, list):
1706
+ codes2group[i] = cltmisc.build_indices(v)
1707
+
1708
+ # Convert the new_codes to a numpy array
1709
+ if new_codes is not None:
1710
+ if isinstance(new_codes, list):
1711
+ new_codes = cltmisc.build_indices(new_codes)
1712
+ new_codes = np.array(new_codes)
1713
+ elif isinstance(new_codes, (str, np.integer, int)):
1714
+ new_codes = np.array([new_codes])
1715
+
1716
+ else:
1717
+ new_codes = np.arange(1, n_groups + 1)
1718
+
1719
+ if len(new_codes) != n_groups:
1720
+ raise ValueError(
1721
+ "The number of new codes must be equal to the number of groups that will be created"
1722
+ )
1723
+
1724
+ # Convert the new_names to a list
1725
+ if new_names is not None:
1726
+ if isinstance(new_names, str):
1727
+ new_names = [new_names]
1728
+
1729
+ if len(new_names) != n_groups:
1730
+ raise ValueError(
1731
+ "The number of new names must be equal to the number of groups that will be created"
1732
+ )
1733
+
1734
+ # Convert the new_colors to a numpy array
1735
+ if new_colors is not None:
1736
+ if isinstance(new_colors, list):
1737
+
1738
+ if isinstance(new_colors[0], str):
1739
+ new_colors = cltmisc.multi_hex2rgb(new_colors)
1740
+
1741
+ elif isinstance(new_colors[0], np.ndarray):
1742
+ new_colors = np.array(new_colors)
1743
+
1744
+ else:
1745
+ raise ValueError(
1746
+ "If new_colors is a list, it must be a list of hexadecimal colors or a list of rgb colors"
1747
+ )
1748
+
1749
+ elif isinstance(new_colors, np.ndarray):
1750
+ pass
1751
+
1752
+ else:
1753
+ raise ValueError(
1754
+ "The new_colors must be a list of colors or a numpy array"
1755
+ )
1756
+
1757
+ new_colors = cltmisc.readjust_colors(new_colors)
1758
+
1759
+ if new_colors.shape[0] != n_groups:
1760
+ raise ValueError(
1761
+ "The number of new colors must be equal to the number of groups that will be created"
1762
+ )
1763
+
1764
+ # Creating the grouped parcellation
1765
+ out_atlas = np.zeros_like(self.data, dtype="int16")
1766
+
1767
+ for i in range(n_groups):
1768
+ indexes = cltmisc.get_indexes_by_substring(
1769
+ input_list=self.name, substr=names2group[i]
1770
+ )
1771
+ code2look = np.array(indexes) + 1
1772
+
1773
+ if new_codes is not None:
1774
+ out_atlas[np.isin(self.data, code2look) == True] = new_codes[i]
1775
+ else:
1776
+ out_atlas[np.isin(self.data, code2look) == True] = i + 1
1777
+
1778
+ self.data = out_atlas
1779
+
1780
+ if new_codes is not None:
1781
+ self.index = new_codes.tolist()
1782
+
1783
+ if new_names is not None:
1784
+ self.name = new_names
1785
+ else:
1786
+ # If new_names is not provided, the names will be created
1787
+ self.name = ["group_{}".format(i) for i in new_codes]
1788
+
1789
+ if new_colors is not None:
1790
+ self.color = new_colors
1791
+ else:
1792
+ # If new_colors is not provided, the colors will be created
1793
+ self.color = cltmisc.create_random_colors(n_groups)
1794
+
1795
+ # Detect minimum and maximum labels
1796
+ self.parc_range()
1797
+
1798
+ ######################################################################################################
1799
+ def rearrange_parc(self, offset: int = 0):
1800
+ """
1801
+ Rearrange parcellation labels to consecutive integers.
1802
+
1803
+ Parameters
1804
+ ----------
1805
+ offset : int, optional
1806
+ Starting value for rearranged labels. Default is 0 (starts from 1).
1807
+
1808
+ Examples
1809
+ --------
1810
+ >>> # Rearrange to 1, 2, 3, ...
1811
+ >>> parc.rearrange_parc()
1812
+ >>>
1813
+ >>> # Start from 100
1814
+ >>> parc.rearrange_parc(offset=99)
1815
+ """
1816
+
1817
+ st_codes = np.unique(self.data)
1818
+ st_codes = st_codes[st_codes != 0]
1819
+
1820
+ # Parcellation with values starting from 1 or starting from the offset
1821
+ new_parc = np.zeros_like(self.data, dtype="int16")
1822
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
1823
+ if len(self.index) > 0:
1824
+ for i, code in enumerate(self.index):
1825
+ new_parc[self.data == code] = i + 1 + offset
1826
+
1827
+ else:
1828
+ for i, code in enumerate(st_codes):
1829
+ new_parc[self.data == code] = i + 1 + offset
1830
+
1831
+ self.data = new_parc
1832
+ else:
1833
+
1834
+ for i, code in enumerate(st_codes):
1835
+ new_parc[self.data == code] = i + 1 + offset
1836
+ self.data = new_parc
1837
+
1838
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
1839
+ temp_index = np.unique(self.data)
1840
+ temp_index = temp_index[temp_index != 0]
1841
+ self.index = temp_index.tolist()
1842
+
1843
+ self.parc_range()
1844
+
1845
+ ######################################################################################################
1846
+ def add_parcellation(self, parc2add, append: bool = False):
1847
+ """
1848
+ Combine another parcellation into current object.
1849
+
1850
+ Parameters
1851
+ ----------
1852
+ parc2add : Parcellation or list
1853
+ Parcellation object(s) to add.
1854
+
1855
+ append : bool, optional
1856
+ If True, adds new labels by offsetting. If False, overlays directly. Default is False.
1857
+
1858
+ Examples
1859
+ --------
1860
+ >>> # Overlay parcellations
1861
+ >>> parc1.add_parcellation(parc2, append=False)
1862
+ >>>
1863
+ >>> # Append with new labels
1864
+ >>> parc1.add_parcellation(parc2, append=True)
1865
+ """
1866
+
1867
+ if isinstance(parc2add, Parcellation):
1868
+ parc2add = [parc2add]
1869
+
1870
+ if isinstance(parc2add, list):
1871
+ if len(parc2add) > 0:
1872
+ for parc in parc2add:
1873
+ tmp_parc_obj = copy.deepcopy(parc)
1874
+ if isinstance(parc, Parcellation):
1875
+ ind = np.where(tmp_parc_obj.data != 0)
1876
+ if append:
1877
+ tmp_parc_obj.data[ind] = (
1878
+ tmp_parc_obj.data[ind] + self.maxlab
1879
+ )
1880
+
1881
+ if (
1882
+ hasattr(parc, "index")
1883
+ and hasattr(parc, "name")
1884
+ and hasattr(parc, "color")
1885
+ ):
1886
+ if (
1887
+ hasattr(self, "index")
1888
+ and hasattr(self, "name")
1889
+ and hasattr(self, "color")
1890
+ ):
1891
+
1892
+ if append:
1893
+ # Adjust the values of the index
1894
+ tmp_parc_obj.index = [
1895
+ int(x + self.maxlab) for x in tmp_parc_obj.index
1896
+ ]
1897
+
1898
+ if isinstance(tmp_parc_obj.index, list) and isinstance(
1899
+ self.index, list
1900
+ ):
1901
+ self.index = self.index + tmp_parc_obj.index
1902
+
1903
+ elif isinstance(
1904
+ tmp_parc_obj.index, np.ndarray
1905
+ ) and isinstance(self.index, np.ndarray):
1906
+ self.index = np.concatenate(
1907
+ (self.index, tmp_parc_obj.index), axis=0
1908
+ ).tolist()
1909
+
1910
+ elif isinstance(
1911
+ tmp_parc_obj.index, list
1912
+ ) and isinstance(self.index, np.ndarray):
1913
+ self.index = (
1914
+ tmp_parc_obj.index + self.index.tolist()
1915
+ )
1916
+
1917
+ elif isinstance(
1918
+ tmp_parc_obj.index, np.ndarray
1919
+ ) and isinstance(self.index, list):
1920
+ self.index = (
1921
+ self.index + tmp_parc_obj.index.tolist()
1922
+ )
1923
+
1924
+ self.name = self.name + tmp_parc_obj.name
1925
+
1926
+ if isinstance(tmp_parc_obj.color, list) and isinstance(
1927
+ self.color, list
1928
+ ):
1929
+ self.color = self.color + tmp_parc_obj.color
1930
+
1931
+ elif isinstance(
1932
+ tmp_parc_obj.color, np.ndarray
1933
+ ) and isinstance(self.color, np.ndarray):
1934
+ self.color = np.concatenate(
1935
+ (self.color, tmp_parc_obj.color), axis=0
1936
+ )
1937
+
1938
+ elif isinstance(
1939
+ tmp_parc_obj.color, list
1940
+ ) and isinstance(self.color, np.ndarray):
1941
+ temp_color = cltmisc.readjust_colors(self.color)
1942
+ temp_color = cltmisc.multi_rgb2hex(temp_color)
1943
+
1944
+ self.color = temp_color + tmp_parc_obj.color
1945
+ elif isinstance(
1946
+ tmp_parc_obj.color, np.ndarray
1947
+ ) and isinstance(self.color, list):
1948
+ temp_color = cltmisc.readjust_colors(
1949
+ tmp_parc_obj.color
1950
+ )
1951
+ temp_color = cltmisc.multi_rgb2hex(temp_color)
1952
+
1953
+ self.color = self.color + temp_color
1954
+
1955
+ # If the parcellation self.data is all zeros
1956
+ elif np.sum(self.data) == 0:
1957
+ self.index = tmp_parc_obj.index
1958
+ self.name = tmp_parc_obj.name
1959
+ self.color = tmp_parc_obj.color
1960
+
1961
+ # Concatenating the parcellation data
1962
+ self.data[ind] = tmp_parc_obj.data[ind]
1963
+
1964
+ else:
1965
+ raise ValueError("The list is empty")
1966
+
1967
+ if hasattr(self, "color"):
1968
+ self.color = cltmisc.harmonize_colors(self.color)
1969
+
1970
+ # Detect minimum and maximum labels
1971
+ self.parc_range()
1972
+
1973
+ ######################################################################################################
1974
+ def save_parcellation(
1975
+ self,
1976
+ out_file: str,
1977
+ affine: np.float64 = None,
1978
+ headerlines: Union[list, str] = None,
1979
+ save_lut: bool = False,
1980
+ save_tsv: bool = False,
1981
+ ):
1982
+ """
1983
+ Save parcellation to NIfTI file with optional lookup tables.
1984
+
1985
+ Parameters
1986
+ ----------
1987
+ out_file : str
1988
+ Output file path.
1989
+
1990
+ affine : np.ndarray, optional
1991
+ Affine matrix. If None, uses object's affine. Default is None.
1992
+
1993
+ headerlines : list or str, optional
1994
+ Header lines for LUT file. Default is None.
1995
+
1996
+ save_lut : bool, optional
1997
+ Whether to save FreeSurfer LUT file. Default is False.
1998
+
1999
+ save_tsv : bool, optional
2000
+ Whether to save TSV lookup table. Default is False.
2001
+
2002
+ Examples
2003
+ --------
2004
+ >>> # Save with lookup tables
2005
+ >>> parc.save_parcellation('output.nii.gz', save_lut=True, save_tsv=True)
2006
+ """
2007
+
2008
+ if affine is None:
2009
+ affine = self.affine
2010
+
2011
+ if headerlines is not None:
2012
+ if isinstance(headerlines, str):
2013
+ headerlines = [headerlines]
2014
+
2015
+ self.data.astype(np.int32)
2016
+ out_atlas = nib.Nifti1Image(self.data, affine)
2017
+ nib.save(out_atlas, out_file)
2018
+
2019
+ if save_lut:
2020
+ if (
2021
+ hasattr(self, "index")
2022
+ and hasattr(self, "name")
2023
+ and hasattr(self, "color")
2024
+ ):
2025
+ self.export_colortable(
2026
+ out_file=out_file.replace(".nii.gz", ".lut"),
2027
+ headerlines=headerlines,
2028
+ )
2029
+ else:
2030
+ print(
2031
+ "Warning: The parcellation does not contain a color table. The lut file will not be saved"
2032
+ )
2033
+
2034
+ if save_tsv:
2035
+ if (
2036
+ hasattr(self, "index")
2037
+ and hasattr(self, "name")
2038
+ and hasattr(self, "color")
2039
+ ):
2040
+ self.export_colortable(
2041
+ out_file=out_file.replace(".nii.gz", ".tsv"), lut_type="tsv"
2042
+ )
2043
+ else:
2044
+ print(
2045
+ "Warning: The parcellation does not contain a color table. The tsv file will not be saved"
2046
+ )
2047
+
2048
+ ######################################################################################################
2049
+ def load_colortable(self, lut_file: Union[str, dict] = None, lut_type: str = "lut"):
2050
+ """
2051
+ Load lookup table to associate codes with names and colors.
2052
+
2053
+ Parameters
2054
+ ----------
2055
+ lut_file : str or dict, optional
2056
+ Path to LUT file or dictionary with index/name/color keys. Default is None.
2057
+
2058
+ lut_type : str, optional
2059
+ File format: 'lut' or 'tsv'. Default is 'lut'.
2060
+
2061
+ Examples
2062
+ --------
2063
+ >>> # Load FreeSurfer LUT
2064
+ >>> parc.load_colortable('FreeSurferColorLUT.txt', lut_type='lut')
2065
+ >>>
2066
+ >>> # Load TSV table
2067
+ >>> parc.load_colortable('regions.tsv', lut_type='tsv')
2068
+ """
2069
+
2070
+ if lut_file is None:
2071
+ # Get the enviroment variable of $FREESURFER_HOME
2072
+ freesurfer_home = os.getenv("FREESURFER_HOME")
2073
+ lut_file = os.path.join(freesurfer_home, "FreeSurferColorLUT.txt")
2074
+
2075
+ if isinstance(lut_file, str):
2076
+ if os.path.exists(lut_file):
2077
+ self.lut_file = lut_file
2078
+
2079
+ if lut_type == "lut":
2080
+ col_dict = self.read_luttable(in_file=lut_file)
2081
+
2082
+ elif lut_type == "tsv":
2083
+ col_dict = self.read_tsvtable(in_file=lut_file)
2084
+
2085
+ else:
2086
+ raise ValueError("The lut_type must be 'lut' or 'tsv'")
2087
+
2088
+ if "index" in col_dict.keys() and "name" in col_dict.keys():
2089
+ st_codes = col_dict["index"]
2090
+ st_names = col_dict["name"]
2091
+ else:
2092
+ raise ValueError(
2093
+ "The dictionary must contain the keys 'index' and 'name'"
2094
+ )
2095
+
2096
+ if "color" in col_dict.keys():
2097
+ st_colors = col_dict["color"]
2098
+ else:
2099
+ st_colors = None
2100
+
2101
+ self.index = st_codes
2102
+ self.name = st_names
2103
+ self.color = st_colors
2104
+
2105
+ else:
2106
+ raise ValueError("The lut file does not exist")
2107
+
2108
+ elif isinstance(lut_file, dict):
2109
+ self.lut_file = None
2110
+
2111
+ if "index" not in lut_file.keys() or "name" not in lut_file.keys():
2112
+ raise ValueError(
2113
+ "The dictionary must contain the keys 'index' and 'name'"
2114
+ )
2115
+
2116
+ self.index = lut_file["index"]
2117
+ self.name = lut_file["name"]
2118
+
2119
+ if "color" not in lut_file.keys():
2120
+ self.color = None
2121
+ else:
2122
+ self.color = lut_file["color"]
2123
+
2124
+ self.adjust_values()
2125
+ self.parc_range()
2126
+
2127
+ ######################################################################################################
2128
+ def sort_index(self):
2129
+ """
2130
+ Sort index, name, and color attributes by index values.
2131
+
2132
+ Examples
2133
+ --------
2134
+ >>> parc.sort_index()
2135
+ >>> print(f"First region: {parc.name[0]} (code: {parc.index[0]})")
2136
+ """
2137
+
2138
+ # Sort the all_index and apply the order to all_name and all_color
2139
+ sort_index = np.argsort(self.index)
2140
+ self.index = [self.index[i] for i in sort_index]
2141
+ self.name = [self.name[i] for i in sort_index]
2142
+ self.color = [self.color[i] for i in sort_index]
2143
+
2144
+ ######################################################################################################
2145
+ def export_colortable(
2146
+ self,
2147
+ out_file: str,
2148
+ lut_type: str = "lut",
2149
+ headerlines: Union[list, str] = None,
2150
+ force: bool = True,
2151
+ ):
2152
+ """
2153
+ Export lookup table to file.
2154
+
2155
+ Parameters
2156
+ ----------
2157
+ out_file : str
2158
+ Output file path.
2159
+
2160
+ lut_type : str, optional
2161
+ Output format: 'lut' or 'tsv'. Default is 'lut'.
2162
+
2163
+ headerlines : list or str, optional
2164
+ Header lines for LUT format. Default is None.
2165
+
2166
+ force : bool, optional
2167
+ Whether to overwrite existing files. Default is True.
2168
+
2169
+ Examples
2170
+ --------
2171
+ >>> # Export FreeSurfer LUT
2172
+ >>> parc.export_colortable('regions.lut', lut_type='lut')
2173
+ >>>
2174
+ >>> # Export TSV
2175
+ >>> parc.export_colortable('regions.tsv', lut_type='tsv')
2176
+ """
2177
+
2178
+ if headerlines is not None:
2179
+ if isinstance(headerlines, str):
2180
+ headerlines = [headerlines]
2181
+
2182
+ if (
2183
+ not hasattr(self, "index")
2184
+ or not hasattr(self, "name")
2185
+ or not hasattr(self, "color")
2186
+ ):
2187
+ raise ValueError(
2188
+ "The parcellation does not contain a color table. The index, name and color attributes must be present"
2189
+ )
2190
+
2191
+ # Adjusting the colortable to the values in the parcellation
2192
+ array_3d = self.data
2193
+ unique_codes = np.unique(array_3d)
2194
+ unique_codes = unique_codes[unique_codes != 0]
2195
+
2196
+ mask = np.isin(self.index, unique_codes)
2197
+ indexes = np.where(mask)[0]
2198
+
2199
+ temp_index = np.array(self.index)
2200
+ index_new = temp_index[mask]
2201
+
2202
+ if hasattr(self, "index"):
2203
+ self.index = index_new
2204
+
2205
+ # If name is an attribute of self
2206
+ if hasattr(self, "name"):
2207
+ self.name = [self.name[i] for i in indexes]
2208
+
2209
+ # If color is an attribute of self
2210
+ if hasattr(self, "color"):
2211
+ self.color = [self.color[i] for i in indexes]
2212
+
2213
+ if lut_type == "lut":
2214
+
2215
+ now = datetime.now()
2216
+ date_time = now.strftime("%m/%d/%Y, %H:%M:%S")
2217
+
2218
+ if headerlines is None:
2219
+ headerlines = ["# $Id: {} {} \n".format(out_file, date_time)]
2220
+
2221
+ if os.path.isfile(self.parc_file):
2222
+ headerlines.append(
2223
+ "# Corresponding parcellation: {} \n".format(self.parc_file)
2224
+ )
2225
+
2226
+ headerlines.append(
2227
+ "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format(
2228
+ "#No.", "Label Name:", "R", "G", "B", "A"
2229
+ )
2230
+ )
2231
+
2232
+ self.write_luttable(
2233
+ self.index, self.name, self.color, out_file, headerlines=headerlines
2234
+ )
2235
+ elif lut_type == "tsv":
2236
+
2237
+ if self.index is None or self.name is None:
2238
+ raise ValueError(
2239
+ "The parcellation does not contain a color table. The index and name attributes must be present"
2240
+ )
2241
+
2242
+ tsv_df = pd.DataFrame({"index": np.asarray(self.index), "name": self.name})
2243
+ # Add color if it is present
2244
+ if self.color is not None:
2245
+
2246
+ if isinstance(self.color, list):
2247
+ if isinstance(self.color[0], str):
2248
+ if self.color[0][0] != "#":
2249
+ raise ValueError("The colors must be in hexadecimal format")
2250
+ else:
2251
+ tsv_df["color"] = self.color
2252
+ else:
2253
+ tsv_df["color"] = cltmisc.multi_rgb2hex(self.color)
2254
+
2255
+ elif isinstance(self.color, np.ndarray):
2256
+ tsv_df["color"] = cltmisc.multi_rgb2hex(self.color)
2257
+
2258
+ self.write_tsvtable(tsv_df, out_file, force=force)
2259
+ else:
2260
+ raise ValueError("The lut_type must be 'lut' or 'tsv'")
2261
+
2262
+ ######################################################################################################
2263
+ def replace_values(
2264
+ self,
2265
+ codes2rep: Union[List[Union[int, List[int]]], np.ndarray],
2266
+ new_codes: Union[int, List[int], np.ndarray],
2267
+ ) -> None:
2268
+ """
2269
+ Replace region codes with new values, supporting group replacements.
2270
+
2271
+ Parameters
2272
+ ----------
2273
+ codes2rep : list or np.ndarray
2274
+ Codes to replace. Can be flat list for individual replacement
2275
+ or list of lists for group replacement.
2276
+
2277
+ new_codes : int, list, or np.ndarray
2278
+ New codes to replace with. Must match number of groups.
2279
+
2280
+ Raises
2281
+ ------
2282
+ ValueError
2283
+ If number of new codes doesn't match number of groups.
2284
+
2285
+ Examples
2286
+ --------
2287
+ >>> # Replace individual codes
2288
+ >>> parc.replace_values([1, 2, 3], [10, 20, 30])
2289
+ >>>
2290
+ >>> # Group replacement
2291
+ >>> parc.replace_values([[1, 2], [3, 4]], [100, 200])
2292
+ """
2293
+
2294
+ # Input validation
2295
+ if not hasattr(self, "data"):
2296
+ raise AttributeError("Object must have 'data' attribute")
2297
+
2298
+ # Handle single integer new_codes
2299
+ if isinstance(new_codes, (int, np.integer)):
2300
+ new_codes = [np.int32(new_codes)]
2301
+
2302
+ # Process codes2rep to determine structure and number of groups
2303
+ if isinstance(codes2rep, list):
2304
+ if len(codes2rep) == 0:
2305
+ raise ValueError("codes2rep cannot be empty")
2306
+
2307
+ # Detect whether it's a flat list of ints or a list of lists
2308
+ if all(isinstance(x, (int, np.integer)) for x in codes2rep):
2309
+ # Interpret as individual values -> multiple groups
2310
+ codes2rep = [[x] for x in codes2rep]
2311
+ elif all(isinstance(x, list) for x in codes2rep):
2312
+ pass # Already in group form
2313
+ else:
2314
+ raise TypeError(
2315
+ "codes2rep must be a list of ints or a list of lists of ints"
2316
+ )
2317
+ n_groups = len(codes2rep)
2318
+
2319
+ elif isinstance(codes2rep, np.ndarray):
2320
+ if codes2rep.ndim == 1:
2321
+ codes2rep = [[int(x)] for x in codes2rep.tolist()]
2322
+ else:
2323
+ raise TypeError("Unsupported numpy array shape for codes2rep")
2324
+ n_groups = len(codes2rep)
2325
+ else:
2326
+ raise TypeError(
2327
+ f"codes2rep must be list or numpy array, got {type(codes2rep)}"
2328
+ )
2329
+
2330
+ # Optionally convert codes using cltmisc.build_indices if available
2331
+ for i, group in enumerate(codes2rep):
2332
+ codes2rep[i] = cltmisc.build_indices(group, nonzeros=False)
2333
+
2334
+ # Process new_codes
2335
+ if isinstance(new_codes, list):
2336
+ new_codes = cltmisc.build_indices(new_codes, nonzeros=False)
2337
+ new_codes = np.array(new_codes, dtype=np.int32)
2338
+
2339
+ elif isinstance(new_codes, (int, np.integer)):
2340
+ new_codes = np.array([new_codes], dtype=np.int32)
2341
+ else:
2342
+ new_codes = np.array(new_codes, dtype=np.int32)
2343
+
2344
+ # Validate matching lengths
2345
+ if len(new_codes) != n_groups:
2346
+ raise ValueError(
2347
+ f"Number of new codes ({len(new_codes)}) must equal "
2348
+ f"number of groups ({n_groups}) to be replaced"
2349
+ )
2350
+
2351
+ # Perform replacements
2352
+ for group_idx in range(n_groups):
2353
+ codes_to_replace = np.array(codes2rep[group_idx])
2354
+ mask = np.isin(self.data, codes_to_replace)
2355
+ self.data[mask] = new_codes[group_idx]
2356
+
2357
+ # Optional post-processing
2358
+ if hasattr(self, "index") and hasattr(self, "name") and hasattr(self, "color"):
2359
+ if hasattr(self, "adjust_values"):
2360
+ self.adjust_values()
2361
+
2362
+ if hasattr(self, "parc_range"):
2363
+ self.parc_range()
2364
+
2365
+ ######################################################################################################
2366
+ def parc_range(self) -> None:
2367
+ """
2368
+ Update minimum and maximum label values in parcellation.
2369
+
2370
+ Sets minlab and maxlab attributes based on non-zero values in data.
2371
+
2372
+ Examples
2373
+ --------
2374
+ >>> parc.parc_range()
2375
+ >>> print(f"Label range: {parc.minlab} - {parc.maxlab}")
2376
+ """
2377
+
2378
+ # Get unique non-zero elements
2379
+ unique_codes = np.unique(self.data)
2380
+ nonzero_codes = unique_codes[unique_codes != 0]
2381
+
2382
+ if nonzero_codes.size > 0:
2383
+ self.minlab = np.min(nonzero_codes)
2384
+ self.maxlab = np.max(nonzero_codes)
2385
+ else:
2386
+ self.minlab = 0
2387
+ self.maxlab = 0
2388
+
2389
+ #######################################################################################################
2390
+ def compute_morphometry_table(
2391
+ self,
2392
+ output_table: str = None,
2393
+ add_bids_entities: bool = False,
2394
+ map_files: Union[str, list] = None,
2395
+ map_ids: Union[str, list] = None,
2396
+ units: Union[str, list] = "unknown",
2397
+ ):
2398
+ """
2399
+ Compute morphometry table for all regions in parcellation.
2400
+ Sets morphometry containing region volumes and statistics.
2401
+
2402
+ Parameters
2403
+ ----------
2404
+ output_table : str, optional
2405
+ Path to save the output table. If None, does not save. Default is None.
2406
+
2407
+ add_bids_entities : bool, optional
2408
+ Whether to add BIDS entities to the output. Default is False.
2409
+
2410
+ map_files : str or list, optional
2411
+ Paths to additional map files for morphometry. If None, only base morphometry is computed.
2412
+ Default is None. This method will compute morphometry for each map file provided.
2413
+
2414
+ map_ids : str or list, optional
2415
+ IDs for the additional maps. If None, uses filenames as IDs. Default is None.
2416
+
2417
+ units : str or list, optional
2418
+ Units for the additional maps. If None, uses "unknown". Default is "unknown".
2419
+
2420
+ Raises
2421
+ ------
2422
+ TypeError
2423
+ If output_table is not a string path.
2424
+
2425
+ FileNotFoundError
2426
+ If the output directory does not exist.
2427
+
2428
+
2429
+ Examples
2430
+ --------
2431
+ >>> # Compute morphometry table and save to CSV
2432
+ >>> parc.compute_morphometry_table(
2433
+ ... output_table='morphometry.csv',
2434
+ ... add_bids_entities=True,
2435
+ ... map_files=['map1.nii.gz', 'map2.nii.gz'],
2436
+ ... map_ids=['map1', 'map2'],
2437
+ ... units=['mm^3', 'unknown']
2438
+ ... )
2439
+ >>> # Compute morphometry without additional maps
2440
+ >>> parc.compute_morphometry_table(
2441
+ ... output_table='morphometry_base.csv',
2442
+ ... add_bids_entities=False
2443
+ ... )
2444
+ >>> # Compute morphometry with a single map file
2445
+ >>> parc.compute_morphometry_table(
2446
+ ... output_table='morphometry_single.csv',
2447
+ ... add_bids_entities=True,
2448
+ ... map_files='single_map.nii.gz',
2449
+ ... map_ids='single_map',
2450
+ ... units='mm^3'
2451
+ ... )
2452
+ """
2453
+
2454
+ from . import morphometrytools as cltmorpho
2455
+
2456
+ # Add Rich progress bar around the main loop
2457
+ with Progress(
2458
+ SpinnerColumn(),
2459
+ TextColumn("[bold blue]{task.description}", justify="right"),
2460
+ BarColumn(bar_width=None),
2461
+ MofNCompleteColumn(),
2462
+ TextColumn("•"),
2463
+ TimeRemainingColumn(),
2464
+ expand=True,
2465
+ ) as progress:
2466
+
2467
+ cont_maps = 0
2468
+
2469
+ if map_files is not None:
2470
+ if isinstance(map_files, str):
2471
+ map_files = [map_files]
2472
+
2473
+ # Handle map_ids: only replace if None or wrong length
2474
+ if map_ids is None:
2475
+ map_ids = []
2476
+ for f in map_files:
2477
+ map_ids.append(cltmisc.get_real_basename(f))
2478
+ elif isinstance(map_ids, str):
2479
+ map_ids = [map_ids]
2480
+ elif len(map_ids) != len(map_files):
2481
+ # If lengths don't match, generate new ones
2482
+ map_ids = []
2483
+ for f in map_files:
2484
+ map_ids.append(cltmisc.get_real_basename(f))
2485
+
2486
+ # Handle units: same logic as map_ids
2487
+ if units is None:
2488
+ units = ["unknown"] * len(map_files)
2489
+ elif isinstance(units, str):
2490
+ units = [units]
2491
+ elif len(units) != len(map_files):
2492
+ units = ["unknown"] * len(map_files)
2493
+
2494
+ fin_maps = []
2495
+ fin_map_ids = []
2496
+ fin_units = []
2497
+
2498
+ # Check if each map file exists
2499
+ for map_file, map_id, unit in zip(map_files, map_ids, units):
2500
+ if os.path.exists(map_file):
2501
+ cont_maps += 1
2502
+ fin_maps.append(map_file)
2503
+ fin_map_ids.append(map_id)
2504
+ fin_units.append(unit)
2505
+ else:
2506
+ print(f"Warning: Map file not found: {map_file}")
2507
+
2508
+ # Fix: Calculate correct total (base morphometry + number of additional maps)
2509
+ total_tasks = 1 + cont_maps
2510
+
2511
+ # Adding a task to the progress bar with correct total
2512
+ task = progress.add_task("Computing base morphometry", total=total_tasks)
2513
+
2514
+ progress.update(
2515
+ task,
2516
+ description=f"Computing: [bold green]volume[/bold green] ([yellow]cm3[/yellow])",
2517
+ completed=1,
2518
+ )
2519
+
2520
+ # Computing the volume table
2521
+ morphometry_table, *_ = cltmorpho.compute_reg_volume_fromparcellation(
2522
+ self, add_bids_entities=add_bids_entities
2523
+ )
2524
+
2525
+ # If there are additional maps, compute morphometry for each map file
2526
+ if cont_maps > 0:
2527
+ # Compute morphometry for each map file
2528
+ for i, (map_file, map_id, unit) in enumerate(
2529
+ zip(fin_maps, fin_map_ids, fin_units)
2530
+ ):
2531
+ # Update progress description to show current map info
2532
+ progress.update(
2533
+ task,
2534
+ description=f"Processing map: [bold green]{map_id}[/bold green] ([yellow]{unit}[/yellow])",
2535
+ completed=i
2536
+ + 2, # +2 because we already completed the base (1) + current index
2537
+ )
2538
+
2539
+ # Compute morphometry for the current map file
2540
+ df, _, _ = cltmorpho.compute_reg_val_fromparcellation(
2541
+ map_file,
2542
+ self,
2543
+ add_bids_entities=add_bids_entities,
2544
+ metric=map_id,
2545
+ units=unit,
2546
+ )
2547
+ morphometry_table = pd.concat([morphometry_table, df], axis=0)
2548
+
2549
+ # Final update
2550
+ progress.update(
2551
+ task,
2552
+ description=f"[bold blue]Completed[/bold blue] morphometry for {cont_maps} maps",
2553
+ completed=total_tasks,
2554
+ )
2555
+
2556
+ self.morphometry = morphometry_table
2557
+
2558
+ # Saving the morphometry table if output_table is provided
2559
+ if output_table is not None:
2560
+ if isinstance(output_table, str):
2561
+ output_table = Path(output_table)
2562
+ else:
2563
+ raise TypeError("output_table must be a string path")
2564
+
2565
+ # If the directory does not exist, create raise an error
2566
+ if not output_table.parent.exists():
2567
+ raise FileNotFoundError(
2568
+ f"Output directory does not exist: {output_table.parent}"
2569
+ )
2570
+
2571
+ # Save the DataFrame to CSV
2572
+ morphometry_table.to_csv(output_table, index=False)
2573
+ print(f"Saved morphometry table to {output_table}")
2574
+
2575
+ ######################################################################################################
2576
+ def compute_volume_table(self):
2577
+ """
2578
+ Compute volume table for all regions in parcellation.
2579
+
2580
+ Sets volumetable attribute containing region volumes and statistics.
2581
+
2582
+ Examples
2583
+ --------
2584
+ >>> parc.compute_volume_table()
2585
+ >>> volume_df, _ = parc.volumetable
2586
+ >>> print(volume_df.head())
2587
+ """
2588
+
2589
+ from . import morphometrytools as cltmorpho
2590
+
2591
+ volume_table = cltmorpho.compute_reg_volume_fromparcellation(self)
2592
+ self.volumetable = volume_table
2593
+
2594
+ return volume_table
2595
+
2596
+ ######################################################################################################
2597
+ @staticmethod
2598
+ def lut_to_fsllut(lut_file_fs: str, lut_file_fsl: str):
2599
+ """
2600
+ Convert FreeSurfer LUT file to FSL format.
2601
+
2602
+ Parameters
2603
+ ----------
2604
+ lut_file_fs : str
2605
+ Path to FreeSurfer LUT file.
2606
+
2607
+ lut_file_fsl : str
2608
+ Path for output FSL LUT file.
2609
+
2610
+ Examples
2611
+ --------
2612
+ >>> Parcellation.lut_to_fsllut('FreeSurferColorLUT.txt', 'fsl_colors.lut')
2613
+ """
2614
+
2615
+ # Reading FreeSurfer color lut
2616
+ lut_dict = Parcellation.read_luttable(lut_file_fs)
2617
+ st_codes_lut = lut_dict["index"]
2618
+ st_names_lut = lut_dict["name"]
2619
+ st_colors_lut = lut_dict["color"]
2620
+
2621
+ st_colors_lut = cltmisc.multi_hex2rgb(st_colors_lut)
2622
+
2623
+ lut_lines = []
2624
+ for roi_pos, st_code in enumerate(st_codes_lut):
2625
+ st_name = st_names_lut[roi_pos]
2626
+ lut_lines.append(
2627
+ "{:<4} {:>3.5f} {:>3.5f} {:>3.5f} {:<40} ".format(
2628
+ st_code,
2629
+ st_colors_lut[roi_pos, 0] / 255,
2630
+ st_colors_lut[roi_pos, 1] / 255,
2631
+ st_colors_lut[roi_pos, 2] / 255,
2632
+ st_name,
2633
+ )
2634
+ )
2635
+
2636
+ with open(lut_file_fsl, "w") as colorLUT_f:
2637
+ colorLUT_f.write("\n".join(lut_lines))
2638
+
2639
+ ######################################################################################################
2640
+ @staticmethod
2641
+ def lut_to_nilearnlut(
2642
+ input_lut_path: Union[str, Path],
2643
+ output_lut_path: Union[str, Path],
2644
+ overwrite: bool = False,
2645
+ ) -> str:
2646
+ """
2647
+ Convert a FreeSurfer-style color lookup table to nilearn-compatible format.
2648
+
2649
+ This function reads a color lookup table (LUT) file with the format:
2650
+ "#No. Label Name: R G B A" and converts it to a pandas-readable format
2651
+ that nilearn's NiftiLabelsMasker can use.
2652
+
2653
+ Parameters
2654
+ ----------
2655
+ input_lut_path : str or Path
2656
+ Path to the input LUT file. Must exist.
2657
+ Expected format: "label name R G B A" (will be converted to 'index' for nilearn)
2658
+
2659
+ output_lut_path : str or Path
2660
+ Path for the output file. Directory must exist.
2661
+
2662
+ overwrite : bool, optional
2663
+ Whether to overwrite the output file if it already exists.
2664
+ Default: False
2665
+
2666
+ Returns
2667
+ -------
2668
+ str
2669
+ Path to the created nilearn-compatible LUT file
2670
+
2671
+ Raises
2672
+ ------
2673
+ FileNotFoundError
2674
+ If input_lut_path doesn't exist or output directory doesn't exist
2675
+ FileExistsError
2676
+ If output file exists and overwrite=False
2677
+ ValueError
2678
+ If no valid data lines are found in the input file
2679
+
2680
+ Examples
2681
+ --------
2682
+ >>> # Convert LUT file
2683
+ >>> nilearn_lut = convert_colorlut_to_nilearn(
2684
+ ... 'parcellation.lut',
2685
+ ... 'parcellation_nilearn.lut'
2686
+ ... )
2687
+
2688
+ >>> # Convert with overwrite enabled
2689
+ >>> nilearn_lut = convert_colorlut_to_nilearn(
2690
+ ... 'parcellation.lut',
2691
+ ... 'parcellation_nilearn.lut',
2692
+ ... overwrite=True
2693
+ ... )
2694
+
2695
+ >>> # Use with NiftiLabelsMasker
2696
+ >>> from nilearn.maskers import NiftiLabelsMasker
2697
+ >>> masker = NiftiLabelsMasker(labels_img='parcellation.nii.gz', lut=nilearn_lut)
2698
+ """
2699
+ # Convert to Path objects
2700
+ input_path = Path(input_lut_path)
2701
+ output_path = Path(output_lut_path)
2702
+
2703
+ # Check if input file exists
2704
+ if not input_path.exists():
2705
+ raise FileNotFoundError(f"Input LUT file not found: {input_path}")
2706
+
2707
+ # Check if output directory exists
2708
+ if not output_path.parent.exists():
2709
+ raise FileNotFoundError(
2710
+ f"Output directory does not exist: {output_path.parent}"
2711
+ )
2712
+
2713
+ # Check if output file exists and handle overwrite
2714
+ if output_path.exists() and not overwrite:
2715
+ raise FileExistsError(
2716
+ f"Output file already exists: {output_path}. Use overwrite=True to overwrite."
2717
+ )
2718
+
2719
+ print(f"Converting: {input_path} -> {output_path}")
2720
+
2721
+ # Parse the input file
2722
+ data_rows = []
2723
+
2724
+ with open(input_path, "r") as file:
2725
+ for line in file:
2726
+ line = line.strip()
2727
+
2728
+ # Skip empty lines and comments
2729
+ if not line or line.startswith("#"):
2730
+ continue
2731
+
2732
+ # Split line and extract data
2733
+ parts = line.split()
2734
+ if len(parts) >= 6:
2735
+ try:
2736
+ # Format: index name R G B A (nilearn expects 'index' column)
2737
+ label = int(parts[0])
2738
+ r, g, b, a = map(int, parts[-4:]) # Last 4 are RGBA
2739
+ name_parts = parts[1:-4] # Everything between label and RGBA
2740
+ name = "-".join(name_parts) if name_parts else f"region_{label}"
2741
+
2742
+ data_rows.append(
2743
+ {
2744
+ "index": label, # nilearn expects 'index' not 'label'
2745
+ "name": name,
2746
+ "R": r,
2747
+ "G": g,
2748
+ "B": b,
2749
+ "A": a,
2750
+ }
2751
+ )
2752
+ except ValueError:
2753
+ print(f"Warning: Skipping invalid line: {line}")
2754
+ continue
2755
+
2756
+ # Check if we found any data
2757
+ if not data_rows:
2758
+ raise ValueError(f"No valid data lines found in {input_path}")
2759
+
2760
+ # Create DataFrame and save
2761
+ df = pd.DataFrame(data_rows)
2762
+ df = df.sort_values("index").reset_index(drop=True)
2763
+ df.to_csv(output_path, sep=" ", index=False)
2764
+
2765
+ print(f"✓ Converted {len(data_rows)} regions")
2766
+ print(f"✓ Saved to: {output_path}")
2767
+
2768
+ return str(output_path)
2769
+
2770
+ ######################################################################################################
2771
+ @staticmethod
2772
+ def read_luttable(
2773
+ in_file: str, filter_by_name: Union[str, List[str]] = None
2774
+ ) -> dict:
2775
+ """
2776
+ Read FreeSurfer lookup table file.
2777
+
2778
+ Parameters
2779
+ ----------
2780
+ in_file : str
2781
+ Path to LUT file.
2782
+
2783
+ filter_by_name : str or list, optional
2784
+ Filter regions by name substring(s). Default is None.
2785
+
2786
+ Returns
2787
+ -------
2788
+ dict
2789
+ Dictionary with 'index', 'name', and 'color' keys.
2790
+
2791
+ Examples
2792
+ --------
2793
+ >>> lut_dict = Parcellation.read_luttable('FreeSurferColorLUT.txt')
2794
+ >>> print(f"Found {len(lut_dict['index'])} regions")
2795
+ >>>
2796
+ >>> # Filter for hippocampus
2797
+ >>> hippo_dict = Parcellation.read_luttable(
2798
+ ... 'FreeSurferColorLUT.txt',
2799
+ ... filter_by_name='hippocampus'
2800
+ ... )
2801
+ """
2802
+
2803
+ # Read the LUT file content
2804
+ with open(in_file, "r", encoding="utf-8") as f:
2805
+ lut_content = f.readlines()
2806
+
2807
+ # Initialize lists to store parsed data
2808
+ region_codes = []
2809
+ region_names = []
2810
+ region_colors_rgb = []
2811
+
2812
+ # Parse each non-comment line in the file
2813
+ for line in lut_content:
2814
+ # Skip comments and empty lines
2815
+ line = line.strip()
2816
+ if not line or line.startswith("#") or line.startswith("\\\\"):
2817
+ continue
2818
+
2819
+ # Split line into components
2820
+ parts = line.split()
2821
+ if len(parts) < 5: # Need at least code, name, R, G, B
2822
+ continue
2823
+
2824
+ # Extract data
2825
+ try:
2826
+ code = int(parts[0]) # Using Python's built-in int, not numpy.int32
2827
+ name = parts[1]
2828
+ r, g, b = int(parts[2]), int(parts[3]), int(parts[4])
2829
+
2830
+ region_codes.append(code)
2831
+ region_names.append(name)
2832
+ region_colors_rgb.append([r, g, b])
2833
+ except (ValueError, IndexError):
2834
+ # Skip malformed lines
2835
+ continue
2836
+
2837
+ # Convert RGB colors to hex format
2838
+ try:
2839
+ # Use the existing multi_rgb2hex function if available
2840
+ region_colors_hex = cltmisc.multi_rgb2hex(np.array(region_colors_rgb))
2841
+ except (NameError, AttributeError):
2842
+ # Fallback to direct conversion if the function isn't available
2843
+ region_colors_hex = [
2844
+ f"#{r:02x}{g:02x}{b:02x}" for r, g, b in region_colors_rgb
2845
+ ]
2846
+ if filter_by_name is not None:
2847
+ if isinstance(filter_by_name, str):
2848
+ filter_by_name = [filter_by_name]
2849
+
2850
+ filtered_indices = cltmisc.get_indexes_by_substring(
2851
+ region_names, filter_by_name
2852
+ )
2853
+
2854
+ # Filter the LUT based on the provided names
2855
+ # filtered_indices = [
2856
+ # i for i, name in enumerate(region_names) if name in filter_by_name
2857
+ # ]
2858
+ region_codes = [region_codes[i] for i in filtered_indices]
2859
+ region_names = [region_names[i] for i in filtered_indices]
2860
+ region_colors_hex = [region_colors_hex[i] for i in filtered_indices]
2861
+
2862
+ # Create and return the result dictionary
2863
+ return {
2864
+ "index": region_codes, # Now contains standard Python integers
2865
+ "name": region_names,
2866
+ "color": region_colors_hex,
2867
+ }
2868
+
2869
+ ######################################################################################################
2870
+ @staticmethod
2871
+ def read_tsvtable(
2872
+ in_file: str, filter_by_name: Union[str, List[str]] = None
2873
+ ) -> dict:
2874
+ """
2875
+ Read TSV lookup table file.
2876
+
2877
+ Parameters
2878
+ ----------
2879
+ in_file : str
2880
+ Path to TSV file.
2881
+
2882
+ filter_by_name : str or list, optional
2883
+ Filter regions by name substring(s). Default is None.
2884
+
2885
+ Returns
2886
+ -------
2887
+ dict
2888
+ Dictionary with column names as keys.
2889
+
2890
+ Raises
2891
+ ------
2892
+ ValueError
2893
+ If required columns 'index' and 'name' are missing.
2894
+
2895
+ Examples
2896
+ --------
2897
+ >>> tsv_dict = Parcellation.read_tsvtable('regions.tsv')
2898
+ >>> print(f"Columns: {list(tsv_dict.keys())}")
2899
+ """
2900
+
2901
+ # Check if file exists
2902
+ if not os.path.exists(in_file):
2903
+ raise FileNotFoundError(f"TSV file not found: {in_file}")
2904
+
2905
+ try:
2906
+ # Read the TSV file into a pandas DataFrame
2907
+ tsv_df = pd.read_csv(in_file, sep="\t")
2908
+
2909
+ # Check for required columns
2910
+ required_columns = ["index", "name"]
2911
+ missing_columns = [
2912
+ col for col in required_columns if col not in tsv_df.columns
2913
+ ]
2914
+ if missing_columns:
2915
+ raise ValueError(
2916
+ f"TSV file missing required columns: {', '.join(missing_columns)}"
2917
+ )
2918
+
2919
+ # Convert DataFrame to dictionary
2920
+ tsv_dict = tsv_df.to_dict(orient="list")
2921
+
2922
+ # Convert index values to integers
2923
+ if "index" in tsv_dict:
2924
+ tsv_dict["index"] = [int(x) for x in tsv_dict["index"]]
2925
+
2926
+ if filter_by_name is not None:
2927
+ if isinstance(filter_by_name, str):
2928
+ filter_by_name = [filter_by_name]
2929
+
2930
+ filtered_indices = cltmisc.get_indexes_by_substring(
2931
+ tsv_dict["name"], filter_by_name
2932
+ )
2933
+
2934
+ # Filter the TSV based on the provided names
2935
+ tsv_dict = {
2936
+ key: [tsv_dict[key][i] for i in filtered_indices]
2937
+ for key in tsv_dict.keys()
2938
+ }
2939
+
2940
+ return tsv_dict
2941
+
2942
+ except pd.errors.EmptyDataError:
2943
+ raise ValueError("The TSV file is empty or improperly formatted")
2944
+ except pd.errors.ParserError:
2945
+ raise ValueError("The TSV file could not be parsed correctly")
2946
+ except Exception as e:
2947
+ raise ValueError(f"Error reading TSV file: {str(e)}")
2948
+
2949
+ #######################################################################################################
2950
+ @staticmethod
2951
+ def write_luttable(
2952
+ codes: list,
2953
+ names: list,
2954
+ colors: Union[list, np.ndarray],
2955
+ out_file: str = None,
2956
+ headerlines: Union[list, str] = None,
2957
+ boolappend: bool = False,
2958
+ force: bool = True,
2959
+ ):
2960
+ """
2961
+ Write FreeSurfer format lookup table file.
2962
+
2963
+ Parameters
2964
+ ----------
2965
+ codes : list
2966
+ Region codes.
2967
+
2968
+ names : list
2969
+ Region names.
2970
+
2971
+ colors : list or np.ndarray
2972
+ Region colors (RGB or hex).
2973
+
2974
+ out_file : str, optional
2975
+ Output file path. Default is None.
2976
+
2977
+ headerlines : list or str, optional
2978
+ Header lines. Default is None.
2979
+
2980
+ boolappend : bool, optional
2981
+ Whether to append to existing file. Default is False.
2982
+
2983
+ force : bool, optional
2984
+ Whether to overwrite existing files. Default is True.
2985
+
2986
+ Returns
2987
+ -------
2988
+ list
2989
+ List of formatted LUT lines.
2990
+
2991
+ Examples
2992
+ --------
2993
+ >>> Parcellation.write_luttable(
2994
+ ... [1, 2, 3],
2995
+ ... ['region1', 'region2', 'region3'],
2996
+ ... ['#FF0000', '#00FF00', '#0000FF'],
2997
+ ... 'output.lut'
2998
+ ... )
2999
+ """
3000
+
3001
+ # Check if the file already exists and if the force parameter is False
3002
+ if out_file is not None:
3003
+ if os.path.exists(out_file) and not force:
3004
+ print("Warning: The file already exists. It will be overwritten.")
3005
+
3006
+ out_dir = os.path.dirname(out_file)
3007
+ if not os.path.exists(out_dir):
3008
+ os.makedirs(out_dir)
3009
+
3010
+ happend_bool = True # Boolean to append the headerlines
3011
+ if headerlines is None:
3012
+ happend_bool = (
3013
+ False # Only add this if it is the first time the file is created
3014
+ )
3015
+ now = datetime.now()
3016
+ date_time = now.strftime("%m/%d/%Y, %H:%M:%S")
3017
+ headerlines = [
3018
+ "# $Id: {} {} \n".format(out_file, date_time),
3019
+ "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format(
3020
+ "#No.", "Label Name:", "R", "G", "B", "A"
3021
+ ),
3022
+ ]
3023
+
3024
+ elif isinstance(headerlines, str):
3025
+ headerlines = [headerlines]
3026
+
3027
+ elif isinstance(headerlines, list):
3028
+ pass
3029
+
3030
+ else:
3031
+ raise ValueError("The headerlines parameter must be a list or a string")
3032
+
3033
+ if boolappend:
3034
+ if not os.path.exists(out_file):
3035
+ raise ValueError("The file does not exist")
3036
+ else:
3037
+ with open(out_file, "r") as file:
3038
+ luttable = file.readlines()
3039
+
3040
+ luttable = [l.strip("\n\r") for l in luttable]
3041
+ luttable = ["\n" if element == "" else element for element in luttable]
3042
+
3043
+ if happend_bool:
3044
+ luttable = luttable + headerlines
3045
+
3046
+ else:
3047
+ luttable = headerlines
3048
+
3049
+ if isinstance(colors, list):
3050
+ if isinstance(colors[0], str):
3051
+ colors = cltmisc.harmonize_colors(colors)
3052
+ colors = cltmisc.multi_hex2rgb(colors)
3053
+ elif isinstance(colors[0], list):
3054
+ colors = np.array(colors)
3055
+ elif isinstance(colors[0], np.ndarray):
3056
+ colors = np.vstack(colors)
3057
+
3058
+ # Table for parcellation
3059
+ for roi_pos, roi_name in enumerate(names):
3060
+
3061
+ if roi_pos == 0:
3062
+ luttable.append("\n")
3063
+
3064
+ luttable.append(
3065
+ "{:<4} {:<50} {:>3} {:>3} {:>3} {:>3}".format(
3066
+ codes[roi_pos],
3067
+ names[roi_pos],
3068
+ colors[roi_pos, 0],
3069
+ colors[roi_pos, 1],
3070
+ colors[roi_pos, 2],
3071
+ 0,
3072
+ )
3073
+ )
3074
+ luttable.append("\n")
3075
+
3076
+ if out_file is not None:
3077
+ if os.path.isfile(out_file) and force:
3078
+ # Save the lut table
3079
+ with open(out_file, "w") as colorLUT_f:
3080
+ colorLUT_f.write("\n".join(luttable))
3081
+ elif not os.path.isfile(out_file):
3082
+ # Save the lut table
3083
+ with open(out_file, "w") as colorLUT_f:
3084
+ colorLUT_f.write("\n".join(luttable))
3085
+
3086
+ return luttable
3087
+
3088
+ #######################################################################################################
3089
+ @staticmethod
3090
+ def write_tsvtable(
3091
+ tsv_df: Union[pd.DataFrame, dict],
3092
+ out_file: str,
3093
+ boolappend: bool = False,
3094
+ force: bool = False,
3095
+ ):
3096
+ """
3097
+ Write TSV format lookup table file.
3098
+
3099
+ Parameters
3100
+ ----------
3101
+ tsv_df : pd.DataFrame or dict
3102
+ Data to write with index/name/color information.
3103
+
3104
+ out_file : str
3105
+ Output file path.
3106
+
3107
+ boolappend : bool, optional
3108
+ Whether to append to existing file. Default is False.
3109
+
3110
+ force : bool, optional
3111
+ Whether to overwrite existing files. Default is False.
3112
+
3113
+ Returns
3114
+ -------
3115
+ str
3116
+ Output file path.
3117
+
3118
+ Examples
3119
+ --------
3120
+ >>> data = {'index': [1, 2], 'name': ['region1', 'region2']}
3121
+ >>> Parcellation.write_tsvtable(data, 'regions.tsv', force=True)
3122
+ """
3123
+
3124
+ # Check if the file already exists and if the force parameter is False
3125
+ if os.path.exists(out_file) and not force:
3126
+ print("Warning: The TSV file already exists. It will be overwritten.")
3127
+
3128
+ out_dir = os.path.dirname(out_file)
3129
+ if not os.path.exists(out_dir):
3130
+ os.makedirs(out_dir)
3131
+
3132
+ # Table for parcellation
3133
+ # 1. Converting colors to hexidecimal string
3134
+
3135
+ if isinstance(tsv_df, pd.DataFrame):
3136
+ tsv_dict = tsv_df.to_dict(orient="list")
3137
+ else:
3138
+ tsv_dict = tsv_df
3139
+
3140
+ if "name" not in tsv_dict.keys() or "index" not in tsv_dict.keys():
3141
+ raise ValueError("The dictionary must contain the keys 'index' and 'name'")
3142
+
3143
+ codes = tsv_dict["index"]
3144
+ names = tsv_dict["name"]
3145
+
3146
+ if "color" in tsv_dict.keys():
3147
+ temp_colors = tsv_dict["color"]
3148
+
3149
+ if isinstance(temp_colors, list):
3150
+ if isinstance(temp_colors[0], str):
3151
+ if temp_colors[0][0] != "#":
3152
+ raise ValueError("The colors must be in hexadecimal format")
3153
+
3154
+ elif isinstance(temp_colors[0], list):
3155
+ colors = np.array(temp_colors)
3156
+ seg_hexcol = cltmisc.multi_rgb2hex(colors)
3157
+ tsv_dict["color"] = seg_hexcol
3158
+
3159
+ elif isinstance(temp_colors, np.ndarray):
3160
+ seg_hexcol = cltmisc.multi_rgb2hex(temp_colors)
3161
+ tsv_dict["color"] = seg_hexcol
3162
+
3163
+ if boolappend:
3164
+ if not os.path.exists(out_file):
3165
+ raise ValueError("The file does not exist")
3166
+ else:
3167
+ tsv_orig = Parcellation.read_tsvtable(in_file=out_file)
3168
+
3169
+ # Create a list with the common keys between tsv_orig and tsv_dict
3170
+ common_keys = list(set(tsv_orig.keys()) & set(tsv_dict.keys()))
3171
+
3172
+ # List all the keys for both dictionaries
3173
+ all_keys = list(set(tsv_orig.keys()) | set(tsv_dict.keys()))
3174
+
3175
+ # Concatenate values for those keys and the rest of the keys that are in tsv_orig add white space
3176
+ for key in common_keys:
3177
+ tsv_orig[key] = tsv_orig[key] + tsv_dict[key]
3178
+
3179
+ for key in all_keys:
3180
+ if key not in common_keys:
3181
+ if key in tsv_orig.keys():
3182
+ tsv_orig[key] = tsv_orig[key] + [""] * len(tsv_dict["name"])
3183
+ elif key in tsv_dict.keys():
3184
+ tsv_orig[key] = [""] * len(tsv_orig["name"]) + tsv_dict[key]
3185
+ else:
3186
+ tsv_orig = tsv_dict
3187
+
3188
+ # Dictionary to dataframe
3189
+ tsv_df = pd.DataFrame(tsv_orig)
3190
+
3191
+ if os.path.isfile(out_file) and force:
3192
+
3193
+ # Save the tsv table
3194
+ with open(out_file, "w+") as tsv_file:
3195
+ tsv_file.write(tsv_df.to_csv(sep="\t", index=False))
3196
+
3197
+ elif not os.path.isfile(out_file):
3198
+ # Save the tsv table
3199
+ with open(out_file, "w+") as tsv_file:
3200
+ tsv_file.write(tsv_df.to_csv(sep="\t", index=False))
3201
+
3202
+ return out_file
3203
+
3204
+ ######################################################################################################
3205
+ def print_properties(self):
3206
+ """
3207
+ Print all attributes and methods of the parcellation object.
3208
+
3209
+ Displays non-private attributes and methods for object inspection.
3210
+
3211
+ Examples
3212
+ --------
3213
+ >>> parc.print_properties()
3214
+ Attributes:
3215
+ data
3216
+ affine
3217
+ index
3218
+ ...
3219
+ Methods:
3220
+ keep_by_code
3221
+ save_parcellation
3222
+ ...
3223
+ """
3224
+
3225
+ # Get and print attributes and methods
3226
+ attributes_and_methods = [
3227
+ attr for attr in dir(self) if not callable(getattr(self, attr))
3228
+ ]
3229
+ methods = [method for method in dir(self) if callable(getattr(self, method))]
3230
+
3231
+ print("Attributes:")
3232
+ for attribute in attributes_and_methods:
3233
+ if not attribute.startswith("__"):
3234
+ print(attribute)
3235
+
3236
+ print("\nMethods:")
3237
+ for method in methods:
3238
+ if not method.startswith("__"):
3239
+ print(method)