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,2970 @@
1
+ import os
2
+ from typing import Union, Tuple, Optional, Dict, List
3
+ import copy
4
+ from pyvista import _vtk, PolyData
5
+ from numpy import split, ndarray
6
+ import json
7
+ import warnings
8
+ import tempfile
9
+
10
+ import pandas as pd
11
+ import nibabel as nib
12
+ from nibabel.processing import resample_from_to
13
+ import numpy as np
14
+
15
+ # Importing local modules
16
+ from . import misctools as cltmisc
17
+ from . import surfacetools as cltsurf
18
+ from . import parcellationtools as cltparc
19
+ from . import bidstools as cltbids
20
+ from . import freesurfertools as cltfree
21
+
22
+
23
+ ####################################################################################################
24
+ ####################################################################################################
25
+ ############ ############
26
+ ############ ############
27
+ ############ Section 1: Methods dedicated to compute metrics from surfaces ############
28
+ ############ ############
29
+ ############ ############
30
+ ####################################################################################################
31
+ ####################################################################################################
32
+ def compute_reg_val_fromannot(
33
+ metric_file: Union[str, np.ndarray],
34
+ parc_file: Union[str, cltfree.AnnotParcellation],
35
+ hemi: str,
36
+ output_table: str = None,
37
+ metric: str = "unknown",
38
+ units: str = None,
39
+ stats_list: Union[str, list] = ["value", "median", "std", "min", "max"],
40
+ table_type: str = "metric",
41
+ include_unknown: bool = False,
42
+ include_global: bool = True,
43
+ add_bids_entities: bool = True,
44
+ ) -> Tuple[pd.DataFrame, np.ndarray, Optional[str]]:
45
+ """
46
+ Compute regional statistics from a surface metric file and an annotation file.
47
+
48
+ This function extracts regional values by combining vertex-wise surface metrics with
49
+ anatomical parcellation data. It supports various statistical measures and output formats.
50
+
51
+ Parameters
52
+ ----------
53
+ metric_file : str or np.ndarray
54
+ Path to the surface map file or array containing metric values for each vertex.
55
+
56
+ parc_file : str or cltfree.AnnotParcellation
57
+ Path to the annotation file or AnnotParcellation object defining regions.
58
+
59
+ hemi : str
60
+ Hemisphere identifier ('lh' or 'rh').
61
+
62
+ output_table : str, optional
63
+ Path to save the resulting table. If None, the table is not saved.
64
+
65
+ metric : str, default="unknown"
66
+ Name of the metric being analyzed. Used for naming columns in the output DataFrame.
67
+
68
+ units : str, optional
69
+ Units of the metric. If None, units are determined from the metric name.
70
+
71
+ stats_list : str or list, default=["value", "median", "std", "min", "max"]
72
+ Statistics to compute for each region. Note: "value" is equivalent to the mean.
73
+
74
+ table_type : str, default="metric"
75
+ Output format specification:
76
+ - "metric": Each column represents a specific statistic for each region
77
+ - "region": Each column represents a region, with rows for different statistics
78
+
79
+ include_unknown : bool, default=False
80
+ Whether to include non-anatomical regions (medialwall, unknown, corpuscallosum).
81
+
82
+ include_global : bool, default=True
83
+ Whether to include hemisphere-wide statistics in the output.
84
+
85
+ add_bids_entities : bool, default=True
86
+ Whether to include BIDS entities as columns in the resulting DataFrame.
87
+
88
+ Returns
89
+ -------
90
+ df : pd.DataFrame
91
+ DataFrame containing the computed regional statistics.
92
+
93
+ metric_vect : np.ndarray
94
+ Array of metric values.
95
+
96
+ output_path : str or None
97
+ Path where the table was saved, or None if no table was saved.
98
+
99
+ Examples
100
+ --------
101
+ Basic usage with default parameters:
102
+
103
+ >>> import os
104
+ >>> import clabtoolkit.morphometrytools as morpho
105
+ >>> hemi = 'lh'
106
+ >>> metric_name = 'thickness'
107
+ >>> fs_dir = os.environ.get('FREESURFER_HOME')
108
+ >>> metric_file = os.path.join(fs_dir, 'subjects', 'bert', 'surf', f'{hemi}.{metric_name}')
109
+ >>> parc_file = os.path.join(fs_dir, 'subjects', 'bert', 'label', f'{hemi}.aparc.annot')
110
+ >>> df_region, metric_values, _ = morpho.compute_reg_val_fromannot(
111
+ ... metric_file, parc_file, hemi, metric=metric_name, include_global=False
112
+ ... )
113
+
114
+ Using region format for output:
115
+
116
+ >>> df_metric, _, _ = morpho.compute_reg_val_fromannot(
117
+ ... metric_file, parc_file, hemi, metric=metric_name,
118
+ ... include_global=False, table_type="region", add_bids_entities=True
119
+ ... )
120
+
121
+ Including hemisphere-wide statistics and saving to file:
122
+
123
+ >>> output_path = '/path/to/output/regional_stats.csv'
124
+ >>> df_global, _, saved_path = morpho.compute_reg_val_fromannot(
125
+ ... metric_file, parc_file, hemi, output_table=output_path,
126
+ ... metric=metric_name, include_global=True
127
+ ... )
128
+ >>> print(df_global.head())
129
+ """
130
+
131
+ # Input validation
132
+ if isinstance(stats_list, str):
133
+ stats_list = [stats_list]
134
+
135
+ stats_list = [stat.lower() for stat in stats_list]
136
+
137
+ if table_type not in ["region", "metric"]:
138
+ raise ValueError(
139
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
140
+ )
141
+
142
+ # Process parcellation file
143
+ if isinstance(parc_file, str):
144
+ if not os.path.exists(parc_file):
145
+ raise FileNotFoundError(f"Annotation file not found: {parc_file}")
146
+
147
+ sparc_data = cltfree.AnnotParcellation()
148
+ sparc_data.load_from_file(parc_file=parc_file)
149
+
150
+ elif isinstance(parc_file, cltfree.AnnotParcellation):
151
+ sparc_data = copy.deepcopy(parc_file)
152
+ else:
153
+ raise TypeError(
154
+ f"parc_file must be a string or AnnotParcellation object, got {type(parc_file)}"
155
+ )
156
+
157
+ # Process metric file
158
+ filename = ""
159
+ if isinstance(metric_file, str):
160
+ if not os.path.exists(metric_file):
161
+ raise FileNotFoundError(f"Metric file not found: {metric_file}")
162
+
163
+ metric_vect = nib.freesurfer.io.read_morph_data(metric_file)
164
+ filename = metric_file
165
+ elif isinstance(metric_file, np.ndarray):
166
+ metric_vect = metric_file
167
+ else:
168
+ raise TypeError(
169
+ f"metric_file must be a string or numpy array, got {type(metric_file)}"
170
+ )
171
+
172
+ # Filter unknown regions if needed
173
+ if not include_unknown:
174
+ tmp_names = sparc_data.regnames
175
+ unk_indexes = cltmisc.get_indexes_by_substring(
176
+ tmp_names, ["medialwall", "unknown", "corpuscallosum"]
177
+ ).astype(int)
178
+
179
+ if len(unk_indexes) > 0:
180
+ unk_codes = sparc_data.regtable[unk_indexes, 4]
181
+ unk_vert = np.isin(sparc_data.codes, unk_codes)
182
+
183
+ sparc_data.codes[unk_vert] = 0
184
+ sparc_data.regnames = np.delete(sparc_data.regnames, unk_indexes).tolist()
185
+ sparc_data.regtable = np.delete(sparc_data.regtable, unk_indexes, axis=0)
186
+
187
+ # Clean up codes that don't exist in the region table
188
+ unique_codes = np.unique(sparc_data.codes)
189
+ not_in_table = np.setdiff1d(unique_codes, sparc_data.regtable[:, 4])
190
+ sparc_data.codes[np.isin(sparc_data.codes, not_in_table)] = 0
191
+
192
+ # Get unique valid region codes
193
+ sts = np.unique(sparc_data.codes)
194
+ sts = sts[sts != 0]
195
+
196
+ # Prepare data structures for results
197
+ dict_of_cols = {}
198
+
199
+ # Compute global hemisphere statistics if requested
200
+ if include_global:
201
+ valid_vertices = np.isin(sparc_data.codes, sparc_data.regtable[:, 4])
202
+ global_stats = stats_from_vector(metric_vect[valid_vertices], stats_list)
203
+ dict_of_cols[f"ctx-{hemi}-hemisphere"] = global_stats
204
+
205
+ # Compute statistics for each region
206
+ for regname in sparc_data.regnames:
207
+ index = cltmisc.get_indexes_by_substring(
208
+ sparc_data.regnames, regname, match_entire_word=True
209
+ )
210
+
211
+ if len(index):
212
+ region_mask = sparc_data.codes == sparc_data.regtable[index, 4]
213
+ region_stats = stats_from_vector(metric_vect[region_mask], stats_list)
214
+ dict_of_cols[regname] = region_stats
215
+ else:
216
+ dict_of_cols[regname] = [0] * len(stats_list)
217
+
218
+ # Create DataFrame
219
+ df = pd.DataFrame.from_dict(dict_of_cols)
220
+
221
+ # Add column prefixes
222
+ colnames = df.columns.tolist()
223
+ colnames = cltmisc.correct_names(colnames, prefix=f"ctx-{hemi}-")
224
+ df.columns = colnames
225
+
226
+ # Format table according to specified type
227
+ if table_type == "region":
228
+ # Create region-oriented table
229
+ df.index = [stat_name.title() for stat_name in stats_list]
230
+ df = df.reset_index()
231
+ df = df.rename(columns={"index": "Statistics"})
232
+ else:
233
+ # Create metric-oriented table
234
+ df = df.T
235
+ df.columns = [stat_name.title() for stat_name in stats_list]
236
+ df = df.reset_index()
237
+ df = df.rename(columns={"index": "Region"})
238
+
239
+ # Split region names into components
240
+ reg_names = df["Region"].str.split("-", expand=True)
241
+ df.insert(0, "Supraregion", reg_names[0])
242
+ df.insert(1, "Hemisphere", reg_names[1])
243
+
244
+ # Add metadata columns
245
+ nrows = df.shape[0]
246
+
247
+ if units is None:
248
+ units = get_units(metric)[0]
249
+
250
+ df.insert(0, "Source", ["vertices"] * nrows)
251
+ df.insert(1, "Metric", [metric] * nrows)
252
+ df.insert(2, "Units", [units] * nrows)
253
+ df.insert(3, "MetricFile", [filename] * nrows)
254
+
255
+ # Add BIDS entities if requested
256
+ if add_bids_entities and isinstance(metric_file, str):
257
+ ent_list = cltbids.entities4table()
258
+ df_add = cltbids.entities_to_table(
259
+ filepath=metric_file, entities_to_extract=ent_list
260
+ )
261
+ df = cltmisc.expand_and_concatenate(df_add, df)
262
+
263
+ # Save table if requested
264
+ if output_table is not None:
265
+ output_dir = os.path.dirname(output_table)
266
+ if output_dir and not os.path.exists(output_dir):
267
+ raise FileNotFoundError(
268
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
269
+ )
270
+
271
+ df.to_csv(output_table, sep="\t", index=False)
272
+
273
+ return df, metric_vect, output_table
274
+
275
+
276
+ ####################################################################################################
277
+ def compute_reg_area_fromsurf(
278
+ surf_file: Union[str, cltsurf.Surface],
279
+ parc_file: Union[str, cltfree.AnnotParcellation],
280
+ hemi: str,
281
+ table_type: str = "metric",
282
+ surf_type: str = "",
283
+ include_unknown: bool = False,
284
+ include_global: bool = True,
285
+ add_bids_entities: bool = True,
286
+ output_table: str = None,
287
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
288
+ """
289
+ Compute surface area for each region defined in an annotation file.
290
+
291
+ This function calculates the area for anatomical regions by combining
292
+ surface mesh data with parcellation information. It supports different
293
+ output formats and can include global hemisphere measurements.
294
+
295
+ Parameters
296
+ ----------
297
+ surf_file : str or cltsurf.Surface
298
+ Path to the surface file or Surface object containing mesh data.
299
+ parc_file : str or cltfree.AnnotParcellation
300
+ Path to the annotation file or AnnotParcellation object defining regions.
301
+ hemi : str
302
+ Hemisphere identifier ('lh' or 'rh').
303
+ table_type : str, default="metric"
304
+ Output format specification:
305
+ - "metric": Each row represents a region with area value in a column
306
+ - "region": Each column represents a region with area values in rows
307
+ surf_type : str, default=""
308
+ Description of the surface type (e.g., "white", "pial"). Used for metadata.
309
+ include_unknown : bool, default=False
310
+ Whether to include non-anatomical regions (medialwall, unknown, corpuscallosum).
311
+ include_global : bool, default=True
312
+ Whether to include hemisphere-wide area calculations in the output.
313
+ add_bids_entities : bool, default=True
314
+ Whether to include BIDS entities as columns in the resulting DataFrame.
315
+ output_table : str, optional
316
+ Path to save the resulting table. If None, the table is not saved.
317
+
318
+ Returns
319
+ -------
320
+ df : pd.DataFrame
321
+ DataFrame containing the computed regional area values.
322
+ output_path : str or None
323
+ Path where the table was saved, or None if no table was saved.
324
+
325
+ Examples
326
+ --------
327
+ Basic usage with default parameters:
328
+
329
+ >>> import os
330
+ >>> import clabtoolkit.morphometrytools as morpho
331
+ >>> fs_dir = os.environ.get('FREESURFER_HOME')
332
+ >>> surf_file = os.path.join(fs_dir, 'subjects', 'fsaverage', 'surf', 'lh.white')
333
+ >>> parc_file = os.path.join(fs_dir, 'subjects', 'fsaverage', 'label', 'lh.aparc.annot')
334
+ >>> df_area, _ = morpho.compute_reg_area_fromsurf(surf_file, parc_file, 'lh', surf_type="white")
335
+ >>> print(df_area.head())
336
+
337
+ Using region format for output:
338
+
339
+ >>> df_region, _ = morpho.compute_reg_area_fromsurf(
340
+ ... surf_file, parc_file, 'lh',
341
+ ... table_type="region", surf_type="white", include_global=False
342
+ ... )
343
+ >>> print(df_region.head())
344
+
345
+ Using Surface and AnnotParcellation objects:
346
+
347
+ >>> import clabtoolkit.surfacetools as cltsurf
348
+ >>> import clabtoolkit.freesurfertools as cltfree
349
+ >>> surf = cltsurf.Surface(surface_file=surf_file)
350
+ >>> annot = cltfree.AnnotParcellation(parc_file=parc_file)
351
+ >>> df_obj, _ = morpho.compute_reg_area_fromsurf(surf, annot, 'lh', surf_type="white")
352
+ >>> print(df_obj.head())
353
+
354
+ Saving results to a file:
355
+
356
+ >>> output_path = '/path/to/area_stats.tsv'
357
+ >>> df_out, saved_path = morpho.compute_reg_area_fromsurf(
358
+ ... surf_file, parc_file, 'lh', output_table=output_path, surf_type="white"
359
+ ... )
360
+ >>> print(f"Table saved to: {saved_path}")
361
+ """
362
+ # Input validation
363
+ if table_type not in ["region", "metric"]:
364
+ raise ValueError(
365
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
366
+ )
367
+
368
+ # Process parcellation file
369
+ if isinstance(parc_file, str):
370
+ if not os.path.exists(parc_file):
371
+ raise FileNotFoundError(f"Annotation file not found: {parc_file}")
372
+
373
+ sparc_data = cltfree.AnnotParcellation()
374
+ sparc_data.load_from_file(parc_file=parc_file)
375
+
376
+ elif isinstance(parc_file, cltfree.AnnotParcellation):
377
+ sparc_data = copy.deepcopy(parc_file)
378
+ else:
379
+ raise TypeError(
380
+ f"parc_file must be a string or AnnotParcellation object, got {type(parc_file)}"
381
+ )
382
+
383
+ # Process surface file
384
+ filename = ""
385
+ if isinstance(surf_file, str):
386
+ if not os.path.exists(surf_file):
387
+ raise FileNotFoundError(f"Surface file not found: {surf_file}")
388
+
389
+ surf = cltsurf.Surface(surface_file=surf_file)
390
+ filename = surf_file
391
+ elif isinstance(surf_file, cltsurf.Surface):
392
+ surf = copy.deepcopy(surf_file)
393
+ else:
394
+ raise TypeError(
395
+ f"surf_file must be a string or Surface object, got {type(surf_file)}"
396
+ )
397
+
398
+ # Extract mesh data
399
+ coords = surf.mesh.points
400
+ cells = surf.mesh.GetPolys()
401
+ c = _vtk.vtk_to_numpy(cells.GetConnectivityArray())
402
+ o = _vtk.vtk_to_numpy(cells.GetOffsetsArray())
403
+ faces = split(c, o[1:-1])
404
+ faces = np.squeeze(faces)
405
+
406
+ # Filter unknown regions if needed
407
+ if not include_unknown:
408
+ tmp_names = sparc_data.regnames
409
+ unk_indexes = cltmisc.get_indexes_by_substring(
410
+ tmp_names, ["medialwall", "unknown", "corpuscallosum"]
411
+ ).astype(int)
412
+
413
+ if len(unk_indexes) > 0:
414
+ unk_codes = sparc_data.regtable[unk_indexes, 4]
415
+ unk_vert = np.isin(sparc_data.codes, unk_codes)
416
+
417
+ sparc_data.codes[unk_vert] = 0
418
+ sparc_data.regnames = np.delete(sparc_data.regnames, unk_indexes).tolist()
419
+ sparc_data.regtable = np.delete(sparc_data.regtable, unk_indexes, axis=0)
420
+
421
+ # Clean up codes that don't exist in the region table
422
+ unique_codes = np.unique(sparc_data.codes)
423
+ not_in_table = np.setdiff1d(unique_codes, sparc_data.regtable[:, 4])
424
+ sparc_data.codes[np.isin(sparc_data.codes, not_in_table)] = 0
425
+
426
+ # Calculate area for each region
427
+ dict_of_cols = {}
428
+
429
+ for regname in sparc_data.regnames:
430
+ # Get the index of the region in the color table
431
+ index = cltmisc.get_indexes_by_substring(
432
+ sparc_data.regnames, regname, match_entire_word=True
433
+ )
434
+
435
+ if len(index):
436
+ # Find vertices belonging to this region
437
+ ind = np.where(sparc_data.codes == sparc_data.regtable[index, 4])
438
+
439
+ # Identify faces with different numbers of vertices in this region
440
+ temp = np.isin(faces, ind).astype(int)
441
+ nps = np.sum(temp, axis=1)
442
+
443
+ # Group faces by how many vertices belong to the region
444
+ reg_faces_3v = np.squeeze(
445
+ faces[np.where(nps == 3), :]
446
+ ) # All vertices in region
447
+ reg_faces_2v = np.squeeze(
448
+ faces[np.where(nps == 2), :]
449
+ ) # Two vertices in region
450
+ reg_faces_1v = np.squeeze(
451
+ faces[np.where(nps == 1), :]
452
+ ) # One vertex in region
453
+
454
+ # Calculate area for each group
455
+ temp_3v, _ = area_from_mesh(coords, reg_faces_3v)
456
+ temp_2v, _ = area_from_mesh(coords, reg_faces_2v)
457
+ temp_1v, _ = area_from_mesh(coords, reg_faces_1v)
458
+
459
+ # Sum areas
460
+ dict_of_cols[regname] = [temp_3v + temp_2v + temp_1v]
461
+ else:
462
+ dict_of_cols[regname] = [0]
463
+
464
+ # Create DataFrame
465
+ df = pd.DataFrame.from_dict(dict_of_cols)
466
+
467
+ # Add global area if requested
468
+ if include_global:
469
+ df.insert(0, f"ctx-{hemi}-hemisphere", df.sum(axis=1))
470
+
471
+ # Add column prefixes
472
+ colnames = df.columns.tolist()
473
+ colnames = cltmisc.correct_names(colnames, prefix=f"ctx-{hemi}-")
474
+ df.columns = colnames
475
+
476
+ # Format table according to specified type
477
+ if table_type == "region":
478
+ # Create region-oriented table
479
+ df.index = ["Value"]
480
+ df = df.reset_index()
481
+ df = df.rename(columns={"index": "Statistics"})
482
+ else:
483
+ # Create metric-oriented table
484
+ df = df.T
485
+ df.columns = ["Value"]
486
+ df = df.reset_index()
487
+ df = df.rename(columns={"index": "Region"})
488
+
489
+ # Split region names into components
490
+ reg_names = df["Region"].str.split("-", expand=True)
491
+ df.insert(0, "Supraregion", reg_names[0])
492
+ df.insert(1, "Hemisphere", reg_names[1])
493
+
494
+ # Add metadata columns
495
+ nrows = df.shape[0]
496
+ units = get_units("area")[0]
497
+
498
+ df.insert(0, "Source", [surf_type] * nrows)
499
+ df.insert(1, "Metric", ["area"] * nrows)
500
+ df.insert(2, "Units", [units] * nrows)
501
+ df.insert(3, "MetricFile", [filename] * nrows)
502
+
503
+ # Add BIDS entities if requested
504
+ if add_bids_entities and isinstance(parc_file, str):
505
+ ent_list = cltbids.entities4table()
506
+ df_add = cltbids.entities_to_table(
507
+ filepath=parc_file, entities_to_extract=ent_list
508
+ )
509
+ df = cltmisc.expand_and_concatenate(df_add, df)
510
+
511
+ # Save table if requested
512
+ if output_table is not None:
513
+ output_dir = os.path.dirname(output_table)
514
+ if output_dir and not os.path.exists(output_dir):
515
+ raise FileNotFoundError(
516
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
517
+ )
518
+
519
+ df.to_csv(output_table, sep="\t", index=False)
520
+
521
+ return df, output_table
522
+
523
+
524
+ ####################################################################################################
525
+ def compute_euler_fromsurf(
526
+ surf_file: Union[str, cltsurf.Surface],
527
+ hemi: str,
528
+ output_table: str = None,
529
+ table_type: str = "metric",
530
+ surf_type: str = "",
531
+ add_bids_entities: bool = True,
532
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
533
+ """
534
+ Compute the Euler characteristic of a surface mesh.
535
+
536
+ This function calculates the Euler characteristic (χ = V - E + F) of a surface mesh,
537
+ which is a topological invariant that provides information about the surface's topology.
538
+
539
+ Parameters
540
+ ----------
541
+ surf_file : str or cltsurf.Surface
542
+ Path to the surface file or Surface object containing the mesh.
543
+ hemi : str
544
+ Hemisphere identifier ('lh' or 'rh').
545
+ output_table : str, optional
546
+ Path to save the resulting table. If None, the table is not saved.
547
+ table_type : str, default="metric"
548
+ Output format specification:
549
+ - "metric": Each column represents a specific metric for each region
550
+ - "region": Each column represents a region, with rows for different metrics
551
+ surf_type : str, default=""
552
+ Type of surface (e.g., "white", "pial") for metadata. If empty, determined from filename.
553
+ add_bids_entities : bool, default=True
554
+ Whether to include BIDS entities as columns in the resulting DataFrame.
555
+
556
+ Returns
557
+ -------
558
+ df : pd.DataFrame
559
+ DataFrame containing the computed Euler characteristic.
560
+ output_path : str or None
561
+ Path where the table was saved, or None if no table was saved.
562
+
563
+ Examples
564
+ --------
565
+ Basic usage with default parameters:
566
+
567
+ >>> import os
568
+ >>> import clabtoolkit.morphometrytools as morpho
569
+ >>> fs_dir = os.environ.get('FREESURFER_HOME')
570
+ >>> hemi = 'lh'
571
+ >>> surf_file = os.path.join(fs_dir, 'subjects', 'bert', 'surf', f'{hemi}.white')
572
+ >>> df, _ = morpho.compute_euler_fromsurf(surf_file, hemi)
573
+ >>> print(df.head())
574
+
575
+ Using region format for output:
576
+
577
+ >>> df_region, _ = morpho.compute_euler_fromsurf(
578
+ ... surf_file, hemi, table_type="region", add_bids_entities=True
579
+ ... )
580
+ >>> print(df_region.head())
581
+
582
+ Saving results to a file:
583
+
584
+ >>> output_path = '/path/to/output/euler_stats.csv'
585
+ >>> df_saved, saved_path = morpho.compute_euler_fromsurf(
586
+ ... surf_file, hemi, output_table=output_path
587
+ ... )
588
+ >>> print(f"Table saved to: {saved_path}")
589
+
590
+ Notes
591
+ -----
592
+ The Euler characteristic (χ) is calculated as χ = V - E + F, where:
593
+ - V is the number of vertices
594
+ - E is the number of edges
595
+ - F is the number of faces
596
+
597
+ For a closed, orientable surface without boundaries, the Euler characteristic
598
+ is related to the genus (g) by the formula: χ = 2 - 2g.
599
+ """
600
+ # Input validation
601
+ if table_type not in ["region", "metric"]:
602
+ raise ValueError(
603
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
604
+ )
605
+
606
+ # Process surface file
607
+ filename = ""
608
+ if isinstance(surf_file, str):
609
+ if not os.path.exists(surf_file):
610
+ raise FileNotFoundError(f"Surface file not found: {surf_file}")
611
+
612
+ surf = cltsurf.Surface(surface_file=surf_file)
613
+ filename = surf_file
614
+
615
+ # Extract surface type from filename if not provided
616
+ if not surf_type and os.path.basename(surf_file).split(".")[-1] not in [
617
+ "gii",
618
+ "vtk",
619
+ ]:
620
+ surf_type = os.path.basename(surf_file).split(".")[-1]
621
+ elif isinstance(surf_file, cltsurf.Surface):
622
+ surf = copy.deepcopy(surf_file)
623
+ else:
624
+ raise TypeError(
625
+ f"surf_file must be a string or Surface object, got {type(surf_file)}"
626
+ )
627
+
628
+ # Extract mesh components
629
+ coords = surf.mesh.points
630
+ cells = surf.mesh.GetPolys()
631
+ c = _vtk.vtk_to_numpy(cells.GetConnectivityArray())
632
+ o = _vtk.vtk_to_numpy(cells.GetOffsetsArray())
633
+ faces = split(c, o[1:-1])
634
+ faces = np.squeeze(faces)
635
+
636
+ # Compute Euler characteristic
637
+ euler = euler_from_mesh(coords, faces)
638
+
639
+ # Create dictionary for DataFrame
640
+ dict_of_cols = {}
641
+ dict_of_cols[f"ctx-{hemi}-hemisphere"] = [euler]
642
+
643
+ # Create DataFrame
644
+ df = pd.DataFrame.from_dict(dict_of_cols)
645
+
646
+ # Add column prefixes
647
+ colnames = df.columns.tolist()
648
+ colnames = cltmisc.correct_names(colnames, prefix=f"ctx-{hemi}-")
649
+ df.columns = colnames
650
+
651
+ # Format table according to specified type
652
+ if table_type == "region":
653
+ # Create region-oriented table
654
+ df.index = ["Value"]
655
+ df = df.reset_index()
656
+ df = df.rename(columns={"index": "Statistics"})
657
+ else:
658
+ # Create metric-oriented table
659
+ df = df.T
660
+ df.columns = ["Value"]
661
+ df = df.reset_index()
662
+ df = df.rename(columns={"index": "Region"})
663
+
664
+ # Split region names into components
665
+ reg_names = df["Region"].str.split("-", expand=True)
666
+ df.insert(0, "Supraregion", reg_names[0])
667
+ df.insert(1, "Hemisphere", reg_names[1])
668
+
669
+ # Add metadata columns
670
+ nrows = df.shape[0]
671
+ units = (
672
+ get_units("euler")[0]
673
+ if isinstance(get_units("euler"), list)
674
+ else get_units("euler")
675
+ )
676
+
677
+ df.insert(0, "Source", [surf_type] * nrows)
678
+ df.insert(1, "Metric", ["euler"] * nrows)
679
+ df.insert(2, "Units", [units] * nrows)
680
+ df.insert(3, "MetricFile", [filename] * nrows)
681
+
682
+ # Add BIDS entities if requested
683
+ if add_bids_entities and isinstance(surf_file, str):
684
+ ent_list = cltbids.entities4table()
685
+ df_add = cltbids.entities_to_table(
686
+ filepath=surf_file, entities_to_extract=ent_list
687
+ )
688
+ df = cltmisc.expand_and_concatenate(df_add, df)
689
+
690
+ # Save table if requested
691
+ output_path = None
692
+ if output_table is not None:
693
+ output_dir = os.path.dirname(output_table)
694
+ if output_dir and not os.path.exists(output_dir):
695
+ raise FileNotFoundError(
696
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
697
+ )
698
+
699
+ df.to_csv(output_table, sep="\t", index=False)
700
+ output_path = output_table
701
+
702
+ return df, output_path
703
+
704
+
705
+ ####################################################################################################
706
+ def area_from_mesh(coords: np.ndarray, faces: np.ndarray) -> Tuple[float, np.ndarray]:
707
+ """
708
+ Compute the total area and per-triangle areas of a mesh surface.
709
+
710
+ This function calculates the area of each triangle in a mesh using Heron's formula
711
+ and returns both the total surface area and individual triangle areas.
712
+
713
+ Parameters
714
+ ----------
715
+ coords : np.ndarray
716
+ Coordinates of the vertices of the mesh.
717
+ Shape must be (n, 3) where n is the number of vertices.
718
+ Each row contains the [x, y, z] coordinates of a vertex.
719
+
720
+ faces : np.ndarray
721
+ Triangular faces of the mesh defined by vertex indices.
722
+ Shape must be (m, 3) where m is the number of faces.
723
+ Each row contains three indices referring to vertices in the coords array.
724
+
725
+ Returns
726
+ -------
727
+ face_area : float
728
+ Total surface area of the mesh in square centimeters (cm²).
729
+
730
+ tri_area : np.ndarray
731
+ Array of areas for each triangle in the mesh in square centimeters (cm²).
732
+ Shape is (m,) where m is the number of faces.
733
+
734
+ Notes
735
+ -----
736
+ The function uses Heron's formula to calculate the area of each triangle:
737
+ Area = √(s(s-a)(s-b)(s-c))
738
+ where s is the semi-perimeter: s = (a + b + c)/2, and a, b, c are the side lengths.
739
+
740
+ The resulting areas are converted to square centimeters (cm²) by dividing by 100
741
+ (assuming the input coordinates are in millimeters).
742
+
743
+ Examples
744
+ --------
745
+ Calculate area of a simple mesh with two triangles:
746
+
747
+ >>> import numpy as np
748
+ >>> coords = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]])
749
+ >>> faces = np.array([[0, 1, 2], [1, 3, 2]])
750
+ >>> total_area, triangle_areas = area_from_mesh(coords, faces)
751
+ >>> print(f"Total area: {total_area:.4f} cm²")
752
+ Total area: 1.0000 cm²
753
+ >>> print(f"Triangle areas: {triangle_areas}")
754
+ Triangle areas: [0.5 0.5]
755
+
756
+ Calculate area of a pyramid:
757
+
758
+ >>> coords = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0.5, 0.5, 1]])
759
+ >>> faces = np.array([[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4], [0, 2, 1], [0, 3, 2]])
760
+ >>> total_area, _ = area_from_mesh(coords, faces)
761
+ >>> print(f"Total area: {total_area:.4f} cm²")
762
+ Total area: ...
763
+ """
764
+ # Input validation
765
+ if coords.ndim != 2 or coords.shape[1] != 3:
766
+ raise ValueError(f"coords must have shape (n, 3), got {coords.shape}")
767
+
768
+ if faces.ndim != 2 or faces.shape[1] != 3:
769
+ raise ValueError(f"faces must have shape (m, 3), got {faces.shape}")
770
+
771
+ if np.any(faces >= coords.shape[0]) or np.any(faces < 0):
772
+ raise ValueError("faces contains invalid vertex indices")
773
+
774
+ # Extract vertex coordinates for each face
775
+ v1 = coords[faces[:, 0]]
776
+ v2 = coords[faces[:, 1]]
777
+ v3 = coords[faces[:, 2]]
778
+
779
+ # Compute edge lengths using Euclidean distance
780
+ d12 = np.sqrt(np.sum((v1 - v2) ** 2, axis=1))
781
+ d23 = np.sqrt(np.sum((v2 - v3) ** 2, axis=1))
782
+ d31 = np.sqrt(np.sum((v3 - v1) ** 2, axis=1))
783
+
784
+ # Compute semi-perimeter for each triangle
785
+ s = (d12 + d23 + d31) / 2
786
+
787
+ # Compute area of each triangle using Heron's formula
788
+ # Division by 100 converts from mm² to cm²
789
+ tri_area = np.sqrt(np.maximum(0, s * (s - d12) * (s - d23) * (s - d31))) / 100
790
+
791
+ # Compute total mesh area
792
+ face_area = np.sum(tri_area)
793
+
794
+ return face_area, tri_area
795
+
796
+
797
+ ####################################################################################################
798
+ def euler_from_mesh(coords: np.ndarray, faces: np.ndarray) -> int:
799
+ """
800
+ Compute the Euler characteristic of a mesh surface.
801
+
802
+ The Euler characteristic (χ) is a topological invariant that describes the shape or
803
+ structure of a topological space regardless of how it is bent or deformed. For a mesh,
804
+ it is calculated as χ = V - E + F, where V is the number of vertices, E is the number
805
+ of edges, and F is the number of faces.
806
+
807
+ Parameters
808
+ ----------
809
+ coords : np.ndarray
810
+ Coordinates of the vertices of the mesh.
811
+ Shape must be (n, 3) where n is the number of vertices.
812
+ Each row contains the [x, y, z] coordinates of a vertex.
813
+
814
+ faces : np.ndarray
815
+ Triangular faces of the mesh defined by vertex indices.
816
+ Shape must be (m, 3) where m is the number of faces.
817
+ Each row contains three indices referring to vertices in the coords array.
818
+
819
+ Returns
820
+ -------
821
+ euler : int
822
+ Euler characteristic of the mesh.
823
+ For a closed manifold surface of genus g, χ = 2 - 2g.
824
+ - Sphere: χ = 2 (genus 0)
825
+ - Torus: χ = 0 (genus 1)
826
+ - Double torus: χ = -2 (genus 2)
827
+
828
+ Notes
829
+ -----
830
+ The Euler characteristic provides information about the topology of a mesh:
831
+ - For closed, orientable surfaces: χ = 2 - 2g, where g is the genus (number of "holes")
832
+ - For surfaces with boundaries (like cortical surfaces): χ = 2 - 2g - b, where b is the
833
+ number of boundary components
834
+
835
+ A change in the Euler characteristic can indicate topological defects in a surface.
836
+
837
+ Examples
838
+ --------
839
+ Calculate Euler characteristic of a tetrahedron (a closed surface):
840
+
841
+ >>> import numpy as np
842
+ >>> coords = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]])
843
+ >>> faces = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]])
844
+ >>> euler = euler_from_mesh(coords, faces)
845
+ >>> print(f"Euler characteristic: {euler}")
846
+ Euler characteristic: 2
847
+
848
+ Calculate Euler characteristic of a simple two-triangle surface:
849
+
850
+ >>> coords = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]])
851
+ >>> faces = np.array([[0, 1, 2], [1, 2, 3]])
852
+ >>> euler = euler_from_mesh(coords, faces)
853
+ >>> print(f"Euler characteristic: {euler}")
854
+ Euler characteristic: 1
855
+ """
856
+ # Input validation
857
+ if coords.ndim != 2 or coords.shape[1] != 3:
858
+ raise ValueError(f"coords must have shape (n, 3), got {coords.shape}")
859
+
860
+ if faces.ndim != 2 or faces.shape[1] != 3:
861
+ raise ValueError(f"faces must have shape (m, 3), got {faces.shape}")
862
+
863
+ if np.any(faces >= coords.shape[0]) or np.any(faces < 0):
864
+ raise ValueError("faces contains invalid vertex indices")
865
+
866
+ # Step 1: Count vertices
867
+ V = coords.shape[0]
868
+
869
+ # Step 2: Count faces
870
+ F = faces.shape[0]
871
+
872
+ # Step 3: Count unique edges
873
+ # Create an array of all edges from faces
874
+ edges = np.vstack(
875
+ [
876
+ faces[:, [0, 1]], # First edge of each face
877
+ faces[:, [1, 2]], # Second edge of each face
878
+ faces[:, [2, 0]], # Third edge of each face
879
+ ]
880
+ )
881
+
882
+ # Sort each edge to ensure (v1,v2) and (v2,v1) are treated as the same edge
883
+ edges = np.sort(edges, axis=1)
884
+
885
+ # Remove duplicate edges using unique
886
+ unique_edges = np.unique(edges, axis=0)
887
+
888
+ # Count edges
889
+ E = len(unique_edges)
890
+
891
+ # Calculate Euler characteristic
892
+ euler = V - E + F
893
+
894
+ return euler
895
+
896
+
897
+ ####################################################################################################
898
+ ####################################################################################################
899
+ ############ ############
900
+ ############ ############
901
+ ############ Section 2: Methods dedicated to compute metrics from parcellations ############
902
+ ############ ############
903
+ ############ ############
904
+ ####################################################################################################
905
+ ####################################################################################################
906
+ def compute_reg_val_fromparcellation(
907
+ metric_file: Union[str, np.ndarray],
908
+ parc_file: Union[str, cltparc.Parcellation, np.ndarray],
909
+ output_table: str = None,
910
+ metric: str = "unknown",
911
+ units: str = None,
912
+ stats_list: Union[str, list] = ["value", "median", "std", "min", "max"],
913
+ table_type: str = "metric",
914
+ exclude_by_code: Union[list, np.ndarray] = None,
915
+ exclude_by_name: Union[list, str] = None,
916
+ add_bids_entities: bool = True,
917
+ region_prefix: str = "supra-side",
918
+ interp_method: str = "linear",
919
+ ) -> Tuple[pd.DataFrame, np.ndarray, Optional[str]]:
920
+ """
921
+ Compute regional statistics from a volumetric metric map and a parcellation.
922
+
923
+ This function extracts regional values by combining voxel-wise volumetric metrics with
924
+ parcellation data defining anatomical regions. It supports various statistical measures
925
+ and output formats to facilitate regional analysis of volumetric neuroimaging data.
926
+
927
+ If the metric and parcellation files have different resolutions, the metric data will be
928
+ automatically resampled to match the parcellation's resolution.
929
+
930
+ Parameters
931
+ ----------
932
+ metric_file : str or np.ndarray
933
+ Path to the volumetric metric file or array containing metric values for each voxel.
934
+ If array, it should have the same dimensions as the parcellation data.
935
+
936
+ parc_file : str, cltparc.Parcellation, or np.ndarray
937
+ Path to the parcellation file, Parcellation object, or numpy array defining regions.
938
+ Each unique integer value in the array represents a different anatomical region.
939
+
940
+ output_table : str, optional
941
+ Path to save the resulting table. If None, the table is not saved.
942
+
943
+ metric : str, default="unknown"
944
+ Name of the metric being analyzed. Used for naming columns in the output DataFrame
945
+ and determining appropriate units.
946
+
947
+ units : str, optional
948
+ Units of the metric being analyzed. If None, units are determined based on the metric.
949
+ Supported units include "intensity", "thickness", "area", "euler", "volume", etc.
950
+ If not specified, the function will attempt to infer units from the metric name.
951
+
952
+ stats_list : str or list, default=["value", "median", "std", "min", "max"]
953
+ Statistics to compute for each region. Note: "value" is equivalent to the mean.
954
+ Supported statistics: "value", "median", "std", "min", "max", "count", "sum".
955
+
956
+ table_type : str, default="metric"
957
+ Output format specification:
958
+ - "metric": Each column represents a specific statistic for each region (regions as rows)
959
+ - "region": Each column represents a region, with rows for different statistics
960
+
961
+ exclude_by_code : list or np.ndarray, optional
962
+ Region codes to exclude from the analysis. If None, no regions are excluded by code.
963
+ Useful for excluding regions like ventricles or non-brain tissue.
964
+
965
+ exclude_by_name : list or str, optional
966
+ Region names to exclude from the analysis. If None, no regions are excluded by name.
967
+ Example: ["Ventricles", "White-Matter"] to focus only on gray matter regions.
968
+
969
+ add_bids_entities : bool, default=True
970
+ Whether to include BIDS entities as columns in the resulting DataFrame.
971
+ This extracts subject, session, and other metadata from the filename.
972
+
973
+ region_prefix : str, default="region-unknown-"
974
+ Prefix to use for region names when they cannot be determined from the parcellation object.
975
+ The prefix will be combined with the region index number.
976
+
977
+ interp_method : str, default="linear"
978
+ Interpolation method to use when resampling the metric data to match parcellation resolution.
979
+ Options include: "linear", "nearest", "cubic". Use "nearest" for categorical data.
980
+
981
+ Returns
982
+ -------
983
+ df : pd.DataFrame
984
+ DataFrame containing the computed regional statistics.
985
+
986
+ metric_data : np.ndarray
987
+ Array of metric values used in the calculation.
988
+
989
+ output_path : str or None
990
+ Path where the table was saved, or None if no table was saved.
991
+
992
+ Examples
993
+ --------
994
+ Basic usage with default parameters:
995
+
996
+ >>> import os
997
+ >>> import clabtoolkit.morphometrytools as morpho
998
+ >>> # Define paths to sample data
999
+ >>> metric_file = os.path.join('data', 'sub-01', 'anat', 'sub-01_T1w_intensity.nii.gz')
1000
+ >>> parc_file = os.path.join('data', 'sub-01', 'anat', 'sub-01_T1w_parcellation.nii.gz')
1001
+ >>> # Compute regional statistics
1002
+ >>> df, metric_values, _ = morpho.compute_reg_val_fromparcellation(
1003
+ ... metric_file, parc_file, metric='intensity'
1004
+ ... )
1005
+ >>> # Display the first few rows of results
1006
+ >>> print(f"Number of regions: {df.shape[0]}")
1007
+ >>> print(df[['Region', 'Value', 'Median', 'Std']].head())
1008
+
1009
+ Using region format for output (regions as columns, statistics as rows):
1010
+
1011
+ >>> df_region, _, _ = morpho.compute_reg_val_fromparcellation(
1012
+ ... metric_file, parc_file, metric='intensity',
1013
+ ... table_type="region", add_bids_entities=True
1014
+ ... )
1015
+ >>> # View statistics across regions
1016
+ >>> print(df_region[['Statistics', 'brain-brain-wholebrain']].head())
1017
+
1018
+ Working with images that have different resolutions:
1019
+
1020
+ >>> # High-resolution functional metric with lower-resolution anatomical parcellation
1021
+ >>> metric_file = os.path.join('data', 'sub-01', 'func', 'sub-01_task-rest_bold.nii.gz')
1022
+ >>> parc_file = os.path.join('data', 'sub-01', 'anat', 'sub-01_T1w_parcellation.nii.gz')
1023
+ >>> # The function will automatically resample the metric to the parcellation's space
1024
+ >>> df, _, _ = morpho.compute_reg_val_fromparcellation(
1025
+ ... metric_file, parc_file, metric='bold',
1026
+ ... interpolation='linear' # Use linear interpolation for continuous data
1027
+ ... )
1028
+ >>> print(df.head())
1029
+
1030
+ Computing only specific statistics for each region:
1031
+
1032
+ >>> df_custom_stats, _, _ = morpho.compute_reg_val_fromparcellation(
1033
+ ... metric_file, parc_file, metric='FA',
1034
+ ... stats_list=['median', 'std'], table_type="metric"
1035
+ ... )
1036
+ >>> # View only median and standard deviation
1037
+ >>> print(df_custom_stats[['Region', 'Median', 'Std']].head())
1038
+
1039
+ Excluding specific regions from analysis:
1040
+
1041
+ >>> # Exclude regions by name
1042
+ >>> exclude_names = ["brain-left-ventricle", "brain-right-ventricle"]
1043
+ >>> df_filtered, _, _ = morpho.compute_reg_val_fromparcellation(
1044
+ ... metric_file, parc_file, metric='thickness',
1045
+ ... exclude_by_name=exclude_names
1046
+ ... )
1047
+ >>> # Check that ventricles are not in results
1048
+ >>> ventricle_count = sum(1 for r in df_filtered['Region'] if 'ventricle' in r.lower())
1049
+ >>> print(f"Ventricle regions in results: {ventricle_count}")
1050
+
1051
+ Saving the results to a file:
1052
+
1053
+ >>> output_path = os.path.join('results', 'sub-01_regional_intensity.tsv')
1054
+ >>> df_saved, _, saved_path = morpho.compute_reg_val_fromparcellation(
1055
+ ... metric_file, parc_file, output_table=output_path,
1056
+ ... metric='intensity'
1057
+ ... )
1058
+ >>> print(f"Table saved to: {saved_path}")
1059
+ >>> # You can load this table later with pandas
1060
+ >>> import pandas as pd
1061
+ >>> df_loaded = pd.read_csv(saved_path, sep='\t')
1062
+
1063
+ Working with in-memory data instead of files:
1064
+
1065
+ >>> import numpy as np
1066
+ >>> import nibabel as nib
1067
+ >>> # Load data into memory first
1068
+ >>> metric_obj = nib.load(metric_file)
1069
+ >>> metric_data = metric_obj.get_fdata()
1070
+ >>> parc_obj = nib.load(parc_file)
1071
+ >>> parc_data = parc_obj.get_fdata()
1072
+ >>> # Process the in-memory arrays
1073
+ >>> df_memory, _, _ = morpho.compute_reg_val_fromparcellation(
1074
+ ... metric_data, parc_data, metric='intensity',
1075
+ ... add_bids_entities=False # No BIDS entities for in-memory data
1076
+ ... )
1077
+ >>> print(df_memory.head())
1078
+
1079
+ Notes
1080
+ -----
1081
+ This function is designed for volumetric data, extracting statistics from voxel-wise
1082
+ metrics within each region defined by a parcellation. For surface-based metrics,
1083
+ consider using `compute_reg_val_fromannot` instead.
1084
+
1085
+ The function handles both file paths and in-memory arrays, making it versatile for
1086
+ different workflows. When working with arrays directly, ensure the metric and
1087
+ parcellation arrays have the same dimensions.
1088
+
1089
+ When metric and parcellation images have different resolutions, the metric data is
1090
+ automatically resampled to match the parcellation's resolution using the specified
1091
+ interpolation method. For continuous metrics (like intensity), linear or cubic
1092
+ interpolation is recommended. For categorical data, use 'nearest' interpolation.
1093
+
1094
+ When working with BIDS-formatted data, setting `add_bids_entities=True` will extract
1095
+ subject, session, and other metadata from the filename to include in the output table.
1096
+
1097
+ See Also
1098
+ --------
1099
+ compute_reg_val_fromannot : Similar function for surface-based metrics and annotations
1100
+ """
1101
+
1102
+ # Input validation
1103
+ if isinstance(stats_list, str):
1104
+ stats_list = [stats_list]
1105
+
1106
+ stats_list = [stat.lower() for stat in stats_list]
1107
+
1108
+ if table_type not in ["region", "metric"]:
1109
+ raise ValueError(
1110
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
1111
+ )
1112
+
1113
+ if interp_method not in ["linear", "nearest", "cubic"]:
1114
+ raise ValueError(
1115
+ f"Invalid interpolation: '{interp_method}'. Expected 'linear', 'nearest', or 'cubic'."
1116
+ )
1117
+
1118
+ # Process parcellation file
1119
+ parc_img = None
1120
+ if isinstance(parc_file, str):
1121
+ if not os.path.exists(parc_file):
1122
+ raise FileNotFoundError(f"Parcellation file not found: {parc_file}")
1123
+
1124
+ parc_img = nib.load(parc_file)
1125
+ vparc_data = cltparc.Parcellation(parc_file=parc_file)
1126
+ elif isinstance(parc_file, cltparc.Parcellation):
1127
+ vparc_data = copy.deepcopy(parc_file)
1128
+ if hasattr(vparc_data, "img"):
1129
+ parc_img = vparc_data.img
1130
+ elif isinstance(parc_file, np.ndarray):
1131
+ vparc_data = cltparc.Parcellation(parc_file=parc_file)
1132
+ else:
1133
+ raise TypeError(
1134
+ f"parc_file must be a string, Parcellation object, or numpy array, got {type(parc_file)}"
1135
+ )
1136
+
1137
+ # Process metric file
1138
+ metric_img = None
1139
+ filename = ""
1140
+ if isinstance(metric_file, str):
1141
+ if not os.path.exists(metric_file):
1142
+ raise FileNotFoundError(f"Metric file not found: {metric_file}")
1143
+
1144
+ metric_img = nib.load(metric_file)
1145
+ metric_vol = metric_img.get_fdata()
1146
+ filename = metric_file
1147
+ elif isinstance(metric_file, np.ndarray):
1148
+ metric_vol = metric_file
1149
+ else:
1150
+ raise TypeError(
1151
+ f"metric_file must be a string or numpy array, got {type(metric_file)}"
1152
+ )
1153
+
1154
+ # Handle resolution mismatch when we have both images
1155
+ temp_file = None
1156
+ if metric_img is not None and parc_img is not None:
1157
+ # Check if dimensions or affines don't match
1158
+ metric_shape = metric_img.shape
1159
+ parc_shape = parc_img.shape
1160
+
1161
+ if (metric_shape != parc_shape) or not np.allclose(
1162
+ metric_img.affine, parc_img.affine
1163
+ ):
1164
+ warnings.warn(
1165
+ f"Metric image ({metric_shape}) and parcellation image ({parc_shape}) have different "
1166
+ f"dimensions or orientations. Resampling metric to match parcellation."
1167
+ )
1168
+
1169
+ # Resample metric to match parcellation space
1170
+ resampled_metric_img = resample_from_to(
1171
+ metric_img,
1172
+ parc_img,
1173
+ order={"linear": 1, "nearest": 0, "cubic": 3}[interp_method],
1174
+ )
1175
+
1176
+ # Save to temporary file if needed for other operations
1177
+ with tempfile.NamedTemporaryFile(suffix=".nii.gz", delete=False) as f:
1178
+ temp_file = f.name
1179
+
1180
+ nib.save(resampled_metric_img, temp_file)
1181
+
1182
+ # Update metric volume with resampled data
1183
+ metric_vol = resampled_metric_img.get_fdata()
1184
+
1185
+ # Check that dimensions match with parcellation data
1186
+ if metric_vol.shape != vparc_data.data.shape:
1187
+ # If we already resampled and still don't match, there's a problem
1188
+ if temp_file:
1189
+ os.unlink(temp_file) # Clean up temp file
1190
+ raise ValueError(
1191
+ f"Resampled metric data shape {metric_vol.shape} still does not match "
1192
+ f"parcellation data shape {vparc_data.data.shape}. Please check your inputs."
1193
+ )
1194
+ else:
1195
+ raise ValueError(
1196
+ f"Metric data shape {metric_vol.shape} does not match parcellation data shape "
1197
+ f"{vparc_data.data.shape}. Use file inputs instead of arrays for automatic resampling."
1198
+ )
1199
+
1200
+ # Apply exclusions if specified
1201
+ if exclude_by_code is not None:
1202
+ vparc_data.remove_by_code(codes2remove=exclude_by_code)
1203
+
1204
+ if exclude_by_name is not None:
1205
+ vparc_data.remove_by_name(names2remove=exclude_by_name)
1206
+
1207
+ # Prepare data structures for results
1208
+ dict_of_cols = {}
1209
+
1210
+ # Compute global brain statistics (non-zero parcellation values)
1211
+ brain_mask = vparc_data.data != 0
1212
+ if np.any(brain_mask): # Check if there are any non-zero values
1213
+ global_stats = stats_from_vector(metric_vol[brain_mask], stats_list)
1214
+ dict_of_cols["brain-brain-wholebrain"] = global_stats
1215
+ else:
1216
+ # Handle empty/invalid parcellation
1217
+ dict_of_cols["brain-brain-wholebrain"] = [0] * len(stats_list)
1218
+
1219
+ # Compute statistics for each region
1220
+ # Use unique region indices from the data itself
1221
+ unique_indices = np.unique(vparc_data.data)
1222
+ unique_indices = unique_indices[unique_indices != 0] # Exclude background
1223
+
1224
+ for index in unique_indices:
1225
+ # Get region name from the parcellation object if available
1226
+ if hasattr(vparc_data, "name") and hasattr(vparc_data, "index"):
1227
+ idx_pos = np.where(np.array(vparc_data.index) == index)[0]
1228
+ if len(idx_pos) > 0:
1229
+ regname = vparc_data.name[idx_pos[0]]
1230
+ else:
1231
+ regname = cltmisc.create_names_from_indices(index, prefix=region_prefix)
1232
+ else:
1233
+ regname = cltmisc.create_names_from_indices(index, prefix=region_prefix)
1234
+
1235
+ region_mask = vparc_data.data == index
1236
+
1237
+ if np.any(region_mask):
1238
+ region_values = metric_vol[region_mask]
1239
+ if len(region_values) > 0: # Check if there are any values
1240
+ region_stats = stats_from_vector(region_values, stats_list)
1241
+ dict_of_cols[regname] = region_stats
1242
+ else:
1243
+ dict_of_cols[regname] = [0] * len(stats_list)
1244
+ else:
1245
+ dict_of_cols[regname] = [0] * len(stats_list)
1246
+
1247
+ # Check if we found any regions
1248
+ if len(dict_of_cols) == 0:
1249
+ if temp_file:
1250
+ os.unlink(temp_file) # Clean up temp file
1251
+ raise ValueError("No valid regions found in the parcellation data")
1252
+
1253
+ # Create DataFrame
1254
+ df = pd.DataFrame.from_dict(dict_of_cols)
1255
+
1256
+ # Format table according to specified type
1257
+ if table_type == "region":
1258
+ # Create region-oriented table
1259
+ df.index = [stat_name.title() for stat_name in stats_list]
1260
+ df = df.reset_index()
1261
+ df = df.rename(columns={"index": "Statistics"})
1262
+ else:
1263
+ # Create metric-oriented table
1264
+ df = df.T
1265
+ df.columns = [stat_name.title() for stat_name in stats_list]
1266
+ df = df.reset_index()
1267
+ df = df.rename(columns={"index": "Region"})
1268
+
1269
+ # Split region names into components
1270
+ reg_names = df["Region"].str.split("-", expand=True)
1271
+
1272
+ # Safely handle region names that might not have 3 components
1273
+ if reg_names.shape[1] >= 3:
1274
+ df.insert(0, "Supraregion", reg_names[0])
1275
+ df.insert(1, "Hemisphere", reg_names[1])
1276
+ elif reg_names.shape[1] == 2:
1277
+ df.insert(0, "Supraregion", reg_names[0])
1278
+ df.insert(1, "Hemisphere", "unknown")
1279
+ else:
1280
+ df.insert(0, "Supraregion", "unknown")
1281
+ df.insert(1, "Hemisphere", "unknown")
1282
+
1283
+ # Add metadata columns
1284
+ nrows = df.shape[0]
1285
+ if units is None:
1286
+ units = get_units(metric)
1287
+
1288
+ if isinstance(units, list) and len(units) > 0:
1289
+ units = units[0]
1290
+ elif units is None or (isinstance(units, list) and len(units) == 0):
1291
+ units = "unknown"
1292
+
1293
+ df.insert(0, "Source", ["volume"] * nrows)
1294
+ df.insert(1, "Metric", [metric] * nrows)
1295
+ df.insert(2, "Units", [units] * nrows)
1296
+ df.insert(3, "MetricFile", [filename] * nrows)
1297
+
1298
+ # Add BIDS entities if requested
1299
+ if add_bids_entities and isinstance(metric_file, str):
1300
+ try:
1301
+ ent_list = cltbids.entities4table()
1302
+ df_add = cltbids.entities_to_table(
1303
+ filepath=metric_file, entities_to_extract=ent_list
1304
+ )
1305
+ df = cltmisc.expand_and_concatenate(df_add, df)
1306
+ except Exception as e:
1307
+ warnings.warn(f"Could not add BIDS entities: {str(e)}")
1308
+
1309
+ # Save table if requested
1310
+ output_path = None
1311
+ if output_table is not None:
1312
+ output_dir = os.path.dirname(output_table)
1313
+ if output_dir and not os.path.exists(output_dir):
1314
+ raise FileNotFoundError(
1315
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
1316
+ )
1317
+
1318
+ df.to_csv(output_table, sep="\t", index=False)
1319
+ output_path = output_table
1320
+
1321
+ # Clean up temporary file if it exists
1322
+ if temp_file and os.path.exists(temp_file):
1323
+ os.unlink(temp_file)
1324
+
1325
+ return df, metric_vol, output_path
1326
+
1327
+
1328
+ ####################################################################################################
1329
+ def compute_reg_volume_fromparcellation(
1330
+ parc_file: Union[str, cltparc.Parcellation, np.ndarray],
1331
+ output_table: str = None,
1332
+ table_type: str = "metric",
1333
+ exclude_by_code: Union[list, np.ndarray] = None,
1334
+ exclude_by_name: Union[list, str] = None,
1335
+ include_by_code: Union[list, np.ndarray] = None,
1336
+ include_by_name: Union[list, str] = None,
1337
+ add_bids_entities: bool = True,
1338
+ region_prefix: str = "supra-side",
1339
+ include_global: bool = True,
1340
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
1341
+ """
1342
+ Compute volume for all regions in a parcellation.
1343
+
1344
+ This function calculates the volume of each region defined in a parcellation by counting
1345
+ the number of voxels in each region and multiplying by the voxel volume. It supports
1346
+ various output formats and can exclude specific regions from the analysis.
1347
+
1348
+ Parameters
1349
+ ----------
1350
+ parc_file : str, cltparc.Parcellation, or np.ndarray
1351
+ Path to the parcellation file, Parcellation object, or numpy array defining regions.
1352
+ Each unique integer value in the array represents a different anatomical region.
1353
+ output_table : str, optional
1354
+ Path to save the resulting table. If None, the table is not saved.
1355
+ table_type : str, default="metric"
1356
+ Output format specification:
1357
+ - "metric": Each column represents a specific statistic for each region (regions as rows)
1358
+ - "region": Each column represents a region, with rows for different statistics
1359
+ exclude_by_code : list or np.ndarray, optional
1360
+ Region codes to exclude from the analysis. If None, no regions are excluded by code.
1361
+ Useful for excluding regions like ventricles or non-brain tissue.
1362
+ exclude_by_name : list or str, optional
1363
+ Region names to exclude from the analysis. If None, no regions are excluded by name.
1364
+ Example: ["Ventricles", "White-Matter"] to focus only on gray matter regions.
1365
+ include_by_code : list or np.ndarray, optional
1366
+ Region codes to include in the analysis. If None, all regions are included.
1367
+ Useful for focusing on specific regions of interest.
1368
+ include_by_name : list or str, optional
1369
+ Region names to include in the analysis. If None, all regions are included.
1370
+ Example: ["Cortex", "Hippocampus"] to focus on specific structures.
1371
+ add_bids_entities : bool, default=True
1372
+ Whether to include BIDS entities as columns in the resulting DataFrame.
1373
+ This extracts subject, session, and other metadata from the filename.
1374
+ region_prefix : str, default="supra-side"
1375
+ Prefix to use for region names when they cannot be determined from the parcellation object.
1376
+ The prefix will be combined with the region index number.
1377
+ include_global : bool, default=True
1378
+ Whether to include a the total volume in the output table.
1379
+ If True, adds a row for the total volume calculated from the parcellation.
1380
+
1381
+ Returns
1382
+ -------
1383
+ df : pd.DataFrame
1384
+ DataFrame containing the computed regional volumes.
1385
+ output_path : str or None
1386
+ Path where the table was saved, or None if no table was saved.
1387
+
1388
+ Examples
1389
+ --------
1390
+ Basic usage with default parameters:
1391
+
1392
+ >>> import os
1393
+ >>> import clabtoolkit.morphometrytools as morpho
1394
+ >>> # Define path to sample data
1395
+ >>> parc_file = os.path.join('data', 'sub-01', 'anat', 'sub-01_T1w_parcellation.nii.gz')
1396
+ >>> # Compute regional volumes
1397
+ >>> df, _ = morpho.compute_reg_volume_fromparcellation(parc_file)
1398
+ >>> # Display the first few rows of results
1399
+ >>> print(f"Number of regions: {df.shape[0]}")
1400
+ >>> print(df[['Region', 'Value']].head())
1401
+
1402
+ Using region format for output (regions as columns):
1403
+
1404
+ >>> df_region, _ = morpho.compute_reg_volume_fromparcellation(
1405
+ ... parc_file, table_type="region", add_bids_entities=True
1406
+ ... )
1407
+ >>> # View volumes across regions
1408
+ >>> print(df_region.head())
1409
+
1410
+ Excluding specific regions from analysis:
1411
+
1412
+ >>> # Exclude regions by name
1413
+ >>> exclude_names = ["brain-left-ventricle", "brain-right-ventricle"]
1414
+ >>> df_filtered, _ = morpho.compute_reg_volume_fromparcellation(
1415
+ ... parc_file, exclude_by_name=exclude_names
1416
+ ... )
1417
+ >>> # Check that ventricles are not in results
1418
+ >>> ventricle_count = sum(1 for r in df_filtered['Region'] if 'ventricle' in r.lower())
1419
+ >>> print(f"Ventricle regions in results: {ventricle_count}")
1420
+
1421
+ Saving the results to a file:
1422
+
1423
+ >>> output_path = os.path.join('results', 'sub-01_regional_volumes.tsv')
1424
+ >>> df_saved, saved_path = morpho.compute_reg_volume_fromparcellation(
1425
+ ... parc_file, output_table=output_path
1426
+ ... )
1427
+ >>> print(f"Table saved to: {saved_path}")
1428
+ >>> # You can load this table later with pandas
1429
+ >>> import pandas as pd
1430
+ >>> df_loaded = pd.read_csv(saved_path, sep='\t')
1431
+
1432
+ Working with in-memory data instead of files:
1433
+
1434
+ >>> import numpy as np
1435
+ >>> import nibabel as nib
1436
+ >>> # Load data into memory first
1437
+ >>> parc_obj = nib.load(parc_file)
1438
+ >>> parc_data = parc_obj.get_fdata()
1439
+ >>> # Create a custom affine matrix (example: 1mm isotropic voxels)
1440
+ >>> affine = np.eye(4)
1441
+ >>> # Process the in-memory array
1442
+ >>> df_memory, _ = morpho.compute_reg_volume_fromparcellation(
1443
+ ... parc_data, add_bids_entities=False
1444
+ ... )
1445
+ >>> print(df_memory.head())
1446
+
1447
+ Notes
1448
+ -----
1449
+ This function calculates volumes in milliliters (ml) by default. The voxel volume is
1450
+ calculated from the affine transformation matrix of the parcellation image. For arrays
1451
+ without an affine matrix, an identity matrix is assumed (1mm isotropic voxels).
1452
+
1453
+ The regional volumes are calculated by counting the number of voxels in each region
1454
+ and multiplying by the voxel volume in cubic millimeters, then dividing by 1000 to
1455
+ convert to milliliters.
1456
+
1457
+ When working with BIDS-formatted data, setting `add_bids_entities=True` will extract
1458
+ subject, session, and other metadata from the filename to include in the output table.
1459
+
1460
+ See Also
1461
+ --------
1462
+ compute_reg_val_fromparcellation : Calculate statistics for metric values within parcellation regions
1463
+ """
1464
+
1465
+ # Input validation
1466
+ if table_type not in ["region", "metric"]:
1467
+ raise ValueError(
1468
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
1469
+ )
1470
+
1471
+ # Process parcellation file
1472
+ filename = ""
1473
+ if isinstance(parc_file, str):
1474
+ if not os.path.exists(parc_file):
1475
+ raise FileNotFoundError(f"Parcellation file not found: {parc_file}")
1476
+
1477
+ vparc_data = cltparc.Parcellation(parc_file=parc_file)
1478
+ affine = vparc_data.affine
1479
+ filename = parc_file
1480
+ elif isinstance(parc_file, cltparc.Parcellation):
1481
+ vparc_data = copy.deepcopy(parc_file)
1482
+ affine = vparc_data.affine
1483
+ filename = vparc_data.parc_file
1484
+ elif isinstance(parc_file, np.ndarray):
1485
+ vparc_data = cltparc.Parcellation(parc_file=parc_file)
1486
+ affine = vparc_data.affine
1487
+ filename = "3darray"
1488
+ else:
1489
+ raise TypeError(
1490
+ f"parc_file must be a string, Parcellation object, or numpy array, got {type(parc_file)}"
1491
+ )
1492
+
1493
+ # Apply exclusions if specified
1494
+ if exclude_by_code is not None:
1495
+ vparc_data.remove_by_code(codes2remove=exclude_by_code)
1496
+
1497
+ if exclude_by_name is not None:
1498
+ vparc_data.remove_by_name(names2remove=exclude_by_name)
1499
+
1500
+ # Apply inclusion if specified
1501
+ if include_by_code is not None:
1502
+ vparc_data.keep_by_code(codes2keep=include_by_code)
1503
+
1504
+ if include_by_name is not None:
1505
+ vparc_data.keep_by_name(names2look=include_by_name)
1506
+
1507
+ # Computing the voxel volume (in cubic mm)
1508
+ vox_size = np.linalg.norm(affine[:3, :3], axis=1)
1509
+ vox_vol = np.prod(vox_size)
1510
+
1511
+ # Prepare data structures for results
1512
+ dict_of_cols = {}
1513
+
1514
+ # Compute global volume for the entire brain (convert to ml by dividing by 1000)
1515
+ if include_global:
1516
+ brain_mask = vparc_data.data != 0
1517
+ if np.any(brain_mask): # Check if there are any non-zero values
1518
+ global_volume_ml = np.sum(brain_mask) * vox_vol / 1000
1519
+ dict_of_cols["brain-brain-wholebrain"] = [global_volume_ml]
1520
+ else:
1521
+ # Handle empty/invalid parcellation
1522
+ dict_of_cols["brain-brain-wholebrain"] = [0]
1523
+
1524
+ # Compute volume for each region
1525
+ # Use unique region indices from the data itself
1526
+ unique_indices = np.unique(vparc_data.data)
1527
+ unique_indices = unique_indices[unique_indices != 0] # Exclude background
1528
+
1529
+ for index in unique_indices:
1530
+ # Get region name from the parcellation object if available
1531
+ if hasattr(vparc_data, "name") and hasattr(vparc_data, "index"):
1532
+ idx_pos = np.where(np.array(vparc_data.index) == index)[0]
1533
+ if len(idx_pos) > 0:
1534
+ regname = vparc_data.name[idx_pos[0]]
1535
+ else:
1536
+ regname = cltmisc.create_names_from_indices(index, prefix=region_prefix)
1537
+ else:
1538
+ regname = cltmisc.create_names_from_indices(index, prefix=region_prefix)
1539
+
1540
+ region_mask = vparc_data.data == index
1541
+ region_volume_ml = np.sum(region_mask) * vox_vol / 1000
1542
+
1543
+ if region_volume_ml > 0:
1544
+ dict_of_cols[regname] = [region_volume_ml]
1545
+ else:
1546
+ dict_of_cols[regname] = [0]
1547
+
1548
+ # Check if we found any regions
1549
+ if len(dict_of_cols) == 0:
1550
+ raise ValueError("No valid regions found in the parcellation data")
1551
+
1552
+ # Create DataFrame
1553
+ df = pd.DataFrame.from_dict(dict_of_cols)
1554
+
1555
+ # Format table according to specified type
1556
+ if table_type == "region":
1557
+ # Create region-oriented table
1558
+ df.index = ["Value"]
1559
+ df = df.reset_index()
1560
+ df = df.rename(columns={"index": "Statistics"})
1561
+ else:
1562
+ # Create metric-oriented table
1563
+ df = df.T
1564
+ df.columns = ["Value"]
1565
+ df = df.reset_index()
1566
+ df = df.rename(columns={"index": "Region"})
1567
+
1568
+ # Split region names into components
1569
+ reg_names = df["Region"].str.split("-", expand=True)
1570
+
1571
+ # Safely handle region names that might not have 3 components
1572
+ if reg_names.shape[1] >= 3:
1573
+ df.insert(0, "Supraregion", reg_names[0])
1574
+ df.insert(1, "Hemisphere", reg_names[1])
1575
+ elif reg_names.shape[1] == 2:
1576
+ df.insert(0, "Supraregion", reg_names[0])
1577
+ df.insert(1, "Hemisphere", "unknown")
1578
+ else:
1579
+ df.insert(0, "Supraregion", "unknown")
1580
+ df.insert(1, "Hemisphere", "unknown")
1581
+
1582
+ # Add metadata columns
1583
+ nrows = df.shape[0]
1584
+ units = get_units("volume")
1585
+ if isinstance(units, list) and len(units) > 0:
1586
+ units = units[0]
1587
+ elif units is None or (isinstance(units, list) and len(units) == 0):
1588
+ units = "ml"
1589
+
1590
+ df.insert(0, "Source", ["parcellation"] * nrows)
1591
+ df.insert(1, "Metric", ["volume"] * nrows)
1592
+ df.insert(2, "Units", [units] * nrows)
1593
+ df.insert(3, "MetricFile", [filename] * nrows)
1594
+
1595
+ # Add BIDS entities if requested
1596
+ if add_bids_entities and isinstance(parc_file, str):
1597
+ try:
1598
+ ent_list = cltbids.entities4table()
1599
+ df_add = cltbids.entities_to_table(
1600
+ filepath=parc_file, entities_to_extract=ent_list
1601
+ )
1602
+ df = cltmisc.expand_and_concatenate(df_add, df)
1603
+ except Exception as e:
1604
+ warnings.warn(f"Could not add BIDS entities: {str(e)}")
1605
+
1606
+ # Save table if requested
1607
+ output_path = None
1608
+ if output_table is not None:
1609
+ output_dir = os.path.dirname(output_table)
1610
+ if output_dir and not os.path.exists(output_dir):
1611
+ raise FileNotFoundError(
1612
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
1613
+ )
1614
+
1615
+ df.to_csv(output_table, sep="\t", index=False)
1616
+ output_path = output_table
1617
+
1618
+ return df, output_path
1619
+
1620
+
1621
+ ####################################################################################################
1622
+ ####################################################################################################
1623
+ ############ ############
1624
+ ############ ############
1625
+ ############ Section 3: Methods dedicated to parse stats file from freesurfer results ############
1626
+ ############ ############
1627
+ ############ ############
1628
+ ####################################################################################################
1629
+ ####################################################################################################
1630
+ def parse_freesurfer_global_fromaseg(
1631
+ stat_file: str,
1632
+ output_table: str = None,
1633
+ table_type: str = "metric",
1634
+ add_bids_entities: bool = True,
1635
+ include_missing: bool = True,
1636
+ config_json: str = None,
1637
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
1638
+ """
1639
+ Parse global volume measurements from a FreeSurfer aseg.stats file.
1640
+
1641
+ This function extracts key global volumetric measurements from FreeSurfer's aseg.stats file,
1642
+ including intracranial volume, brain volume, gray/white matter volumes, and ventricle
1643
+ volumes. It converts values to milliliters and organizes them into a structured DataFrame.
1644
+
1645
+ Parameters
1646
+ ----------
1647
+ stat_file : str
1648
+ Path to the aseg.stats file generated by FreeSurfer.
1649
+ output_table : str, optional
1650
+ Path to save the resulting table. If None, the table is not saved.
1651
+ table_type : str, default="metric"
1652
+ Output format specification:
1653
+ - "metric": Each column represents a specific statistic for each region (regions as rows)
1654
+ - "region": Each column represents a region, with rows for different statistics
1655
+ add_bids_entities : bool, default=True
1656
+ Whether to include BIDS entities as columns in the resulting DataFrame.
1657
+ This extracts subject, session, and other metadata from the filename.
1658
+ include_missing : bool, default=True
1659
+ Whether to include missing values as zeros in the output. If False, missing values
1660
+ will be excluded from the DataFrame.
1661
+ config_json : str, optional
1662
+ Path to a JSON configuration file defining volume measurements to extract.
1663
+ If None, a default configuration will be used.
1664
+
1665
+ Returns
1666
+ -------
1667
+ df : pd.DataFrame
1668
+ DataFrame containing the extracted global volume measurements.
1669
+ output_path : str or None
1670
+ Path where the table was saved, or None if no table was saved.
1671
+ """
1672
+
1673
+ # Verify if the file exists
1674
+ if not os.path.isfile(stat_file):
1675
+ raise FileNotFoundError(f"Stats file not found: {stat_file}")
1676
+
1677
+ # Input validation
1678
+ if table_type not in ["region", "metric"]:
1679
+ raise ValueError(
1680
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
1681
+ )
1682
+
1683
+ # Load volume measurements from config file or use defaults
1684
+ if config_json and os.path.isfile(config_json):
1685
+ try:
1686
+ with open(config_json, "r") as f:
1687
+ config_data = json.load(f)
1688
+ # Check if the config has a "global" key
1689
+ if "global" in config_data:
1690
+ volume_measurements = config_data["global"]
1691
+ else:
1692
+ volume_measurements = config_data
1693
+ except Exception as e:
1694
+ warnings.warn(f"Error loading config file {config_json}: {e}")
1695
+ volume_measurements = get_stats_dictionary("global")
1696
+ else:
1697
+ volume_measurements = get_stats_dictionary("global")
1698
+
1699
+ # Dictionary to store the extracted values
1700
+ extracted_values = {}
1701
+
1702
+ # Read the stats file
1703
+ try:
1704
+ with open(stat_file, "r") as file:
1705
+ file_content = file.readlines()
1706
+
1707
+ # Create dictionaries to store parsed data
1708
+ global_measures = {} # For "# Measure" lines - global stats
1709
+ segmented_data = {} # For tabular data - segmentation stats
1710
+
1711
+ # First, parse all global measures (lines starting with "# Measure")
1712
+ for line in file_content:
1713
+ if line.startswith("# Measure"):
1714
+ try:
1715
+ parts = line.split(", ")
1716
+ if len(parts) >= 4:
1717
+ # Get description (which is parts[2]) and value (parts[3])
1718
+ measure_description = parts[2].strip()
1719
+ measure_value = float(parts[3].strip())
1720
+ global_measures[measure_description] = measure_value
1721
+
1722
+ # Also store by the short name (parts[1]) for alternative lookup
1723
+ if len(parts) >= 2:
1724
+ short_name = parts[1].strip()
1725
+ global_measures[short_name] = measure_value
1726
+ except Exception as e:
1727
+ warnings.warn(
1728
+ f"Error parsing global measure line: {line.strip()}. Error: {e}"
1729
+ )
1730
+
1731
+ # Next, parse segmentation table (lines starting with a number)
1732
+ # First identify where the table starts
1733
+ table_start = False
1734
+ for i, line in enumerate(file_content):
1735
+ if line.startswith("# ColHeaders"):
1736
+ table_start = True
1737
+ continue
1738
+
1739
+ if table_start and line.strip() and line.strip()[0].isdigit():
1740
+ try:
1741
+ parts = line.strip().split()
1742
+ if len(parts) >= 5:
1743
+ # Structure name is in position 4, volume is in position 3
1744
+ seg_name = parts[4]
1745
+ seg_volume = float(parts[3])
1746
+ segmented_data[seg_name] = seg_volume
1747
+ except Exception as e:
1748
+ warnings.warn(
1749
+ f"Error parsing table line: {line.strip()}. Error: {e}"
1750
+ )
1751
+
1752
+ # If we didn't find table data with the precise method, try a more general approach
1753
+ if not segmented_data:
1754
+ for line in file_content:
1755
+ if line.strip() and line.strip()[0].isdigit():
1756
+ try:
1757
+ parts = line.strip().split()
1758
+ if len(parts) >= 5:
1759
+ seg_name = parts[4]
1760
+ seg_volume = float(parts[3])
1761
+ segmented_data[seg_name] = seg_volume
1762
+ except Exception as e:
1763
+ pass # Silently skip lines that don't match expected format
1764
+
1765
+ # Now extract the values based on the configuration
1766
+ for region_key, region_info in volume_measurements.items():
1767
+ value_found = False
1768
+
1769
+ # Get configuration details
1770
+ key = region_info["key"]
1771
+ divisor = region_info.get("divisor", 1000)
1772
+
1773
+ # STEP 1: Try to find in global measures
1774
+ if key in global_measures:
1775
+ value = global_measures[key] / divisor
1776
+ extracted_values[region_key] = [value]
1777
+ value_found = True
1778
+ continue
1779
+
1780
+ # STEP 2: Try to find in segmented data
1781
+ if key in segmented_data:
1782
+ value = segmented_data[key] / divisor
1783
+ extracted_values[region_key] = [value]
1784
+ value_found = True
1785
+ continue
1786
+
1787
+ # STEP 3: Try any alternate keys from config
1788
+ alt_keys = region_info.get("alternate_keys", [])
1789
+ for alt_key in alt_keys:
1790
+ if alt_key in global_measures:
1791
+ value = global_measures[alt_key] / divisor
1792
+ extracted_values[region_key] = [value]
1793
+ value_found = True
1794
+ break
1795
+ if alt_key in segmented_data:
1796
+ value = segmented_data[alt_key] / divisor
1797
+ extracted_values[region_key] = [value]
1798
+ value_found = True
1799
+ break
1800
+
1801
+ if value_found:
1802
+ continue
1803
+
1804
+ # STEP 4: Fallback to line search (legacy method)
1805
+ if "index" in region_info:
1806
+ for line in file_content:
1807
+ if key in line:
1808
+ try:
1809
+ index = region_info["index"]
1810
+ parts = line.split()
1811
+
1812
+ if index < 0:
1813
+ index = len(parts) + index
1814
+
1815
+ if 0 <= index < len(parts):
1816
+ value_str = parts[index].split(",")[0]
1817
+ value = float(value_str) / divisor
1818
+ extracted_values[region_key] = [value]
1819
+ value_found = True
1820
+ break
1821
+ except (IndexError, ValueError) as e:
1822
+ warnings.warn(
1823
+ f"Error parsing value for {region_key} using index: {e}"
1824
+ )
1825
+
1826
+ # If value not found and we're including missing values
1827
+ if not value_found and include_missing:
1828
+ extracted_values[region_key] = [0.0]
1829
+ warnings.warn(
1830
+ f"Value for {region_key} (key: {key}) not found in {stat_file}"
1831
+ )
1832
+
1833
+ except Exception as e:
1834
+ raise RuntimeError(f"Error reading stats file {stat_file}: {e}")
1835
+
1836
+ # Check if we found any values
1837
+ if not extracted_values:
1838
+ raise ValueError(f"No volume measurements found in {stat_file}")
1839
+
1840
+ # Create a dataframe with the values
1841
+ df = pd.DataFrame.from_dict(extracted_values)
1842
+
1843
+ # Format table according to specified type
1844
+ if table_type == "region":
1845
+ # Create region-oriented table
1846
+ df.index = ["Value"]
1847
+ df = df.reset_index()
1848
+ df = df.rename(columns={"index": "Statistics"})
1849
+ else:
1850
+ # Create metric-oriented table
1851
+ df = df.T
1852
+ df.columns = ["Value"]
1853
+
1854
+ # Converting the row names to a new column called statistics
1855
+ df = df.reset_index()
1856
+ df = df.rename(columns={"index": "Region"})
1857
+
1858
+ # Split region names into components
1859
+ reg_names = df["Region"].str.split("-", expand=True)
1860
+
1861
+ # Safely handle region names that might not have 3 components
1862
+ if reg_names.shape[1] >= 3:
1863
+ df.insert(0, "Supraregion", reg_names[0])
1864
+ df.insert(1, "Hemisphere", reg_names[1])
1865
+ elif reg_names.shape[1] == 2:
1866
+ df.insert(0, "Supraregion", reg_names[0])
1867
+ df.insert(1, "Hemisphere", "unknown")
1868
+ else:
1869
+ df.insert(0, "Supraregion", "unknown")
1870
+ df.insert(1, "Hemisphere", "unknown")
1871
+
1872
+ # Add metadata columns
1873
+ nrows = df.shape[0]
1874
+ units = get_units("volume")
1875
+ if isinstance(units, list) and len(units) > 0:
1876
+ units = units[0]
1877
+ elif units is None or (isinstance(units, list) and len(units) == 0):
1878
+ units = "ml"
1879
+
1880
+ df.insert(0, "Source", ["statsfile"] * nrows)
1881
+ df.insert(1, "Metric", ["volume"] * nrows)
1882
+ df.insert(2, "Units", [units] * nrows)
1883
+ df.insert(3, "MetricFile", [stat_file] * nrows)
1884
+
1885
+ # Add BIDS entities if requested
1886
+ if add_bids_entities:
1887
+ try:
1888
+ ent_list = cltbids.entities4table()
1889
+ df_add = cltbids.entities_to_table(
1890
+ filepath=stat_file, entities_to_extract=ent_list
1891
+ )
1892
+ df = cltmisc.expand_and_concatenate(df_add, df)
1893
+ except Exception as e:
1894
+ warnings.warn(f"Could not add BIDS entities: {str(e)}")
1895
+
1896
+ # Save table if requested
1897
+ output_path = None
1898
+ if output_table is not None:
1899
+ output_dir = os.path.dirname(output_table)
1900
+ if output_dir and not os.path.exists(output_dir):
1901
+ raise FileNotFoundError(
1902
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
1903
+ )
1904
+
1905
+ df.to_csv(output_table, sep="\t", index=False)
1906
+ output_path = output_table
1907
+
1908
+ return df, output_path
1909
+
1910
+
1911
+ ####################################################################################################
1912
+ def parse_freesurfer_stats_fromaseg(
1913
+ stat_file: str,
1914
+ output_table: str = None,
1915
+ table_type: str = "metric",
1916
+ add_bids_entities: bool = True,
1917
+ include_missing: bool = True,
1918
+ config_json: str = None,
1919
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
1920
+ """
1921
+ Parse regional volume measurements from a FreeSurfer aseg.stats file.
1922
+
1923
+ This function extracts volume measurements for specific brain regions from the tabular
1924
+ data section of FreeSurfer's aseg.stats file. It converts values to milliliters
1925
+ and organizes them into a structured DataFrame.
1926
+
1927
+ Parameters
1928
+ ----------
1929
+ stat_file : str
1930
+ Path to the aseg.stats file generated by FreeSurfer.
1931
+ output_table : str, optional
1932
+ Path to save the resulting table. If None, the table is not saved.
1933
+ table_type : str, default="metric"
1934
+ Output format specification:
1935
+ - "metric": Each column represents a specific statistic for each region (regions as rows)
1936
+ - "region": Each column represents a region, with rows for different statistics
1937
+ add_bids_entities : bool, default=True
1938
+ Whether to include BIDS entities as columns in the resulting DataFrame.
1939
+ This extracts subject, session, and other metadata from the filename.
1940
+ include_missing : bool, default=True
1941
+ Whether to include missing values as zeros in the output. If False, missing values
1942
+ will be excluded from the DataFrame.
1943
+ config_json : str, optional
1944
+ Path to a JSON configuration file defining region measurements to extract.
1945
+ If None, a default configuration will be used.
1946
+
1947
+ Returns
1948
+ -------
1949
+ df : pd.DataFrame
1950
+ DataFrame containing the extracted region volume measurements.
1951
+ output_path : str or None
1952
+ Path where the table was saved, or None if no table was saved.
1953
+
1954
+ Examples
1955
+ --------
1956
+ Basic usage with default parameters:
1957
+
1958
+ >>> import os
1959
+ >>> import clabtoolkit.morphometrytools as morpho
1960
+ >>> # Define path to FreeSurfer stats file
1961
+ >>> stats_file = os.path.join('freesurfer', 'sub-01', 'stats', 'aseg.stats')
1962
+ >>> # Parse the stats file
1963
+ >>> df, _ = morpho.parse_freesurfer_stats_fromaseg(stats_file)
1964
+ >>> # Display the first few rows of results
1965
+ >>> print(df[['Region', 'Value']].head())
1966
+
1967
+ Using region format (regions as columns, statistics as rows):
1968
+
1969
+ >>> df_region, _ = morpho.parse_freesurfer_stats_fromaseg(
1970
+ ... stats_file, table_type="region"
1971
+ ... )
1972
+ >>> # View volumes across regions
1973
+ >>> print(df_region.head())
1974
+
1975
+ Saving the results to a file:
1976
+
1977
+ >>> output_path = os.path.join('results', 'sub-01_fs-regional-volumes.tsv')
1978
+ >>> df_saved, saved_path = morpho.parse_freesurfer_stats_fromaseg(
1979
+ ... stats_file, output_table=output_path
1980
+ ... )
1981
+ >>> print(f"Table saved to: {saved_path}")
1982
+
1983
+ Notes
1984
+ -----
1985
+ This function extracts regional volume measurements from the table section of the aseg.stats file.
1986
+ The aseg.stats file contains a table with measurements for different brain regions, with
1987
+ each row representing a different segmented region.
1988
+
1989
+ All volumes are converted to milliliters (ml) by dividing the FreeSurfer values
1990
+ (typically in mm³) by 1000.
1991
+
1992
+ The aseg.stats file is typically found in the `[subject]/stats/` directory of a
1993
+ FreeSurfer output directory.
1994
+
1995
+ See Also
1996
+ --------
1997
+ parse_freesurfer_global_fromaseg : Extract global volume measurements from aseg.stats
1998
+ """
1999
+
2000
+ # Verify if the file exists
2001
+ if not os.path.isfile(stat_file):
2002
+ raise FileNotFoundError(f"Stats file not found: {stat_file}")
2003
+
2004
+ # Input validation
2005
+ if table_type not in ["region", "metric"]:
2006
+ raise ValueError(
2007
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
2008
+ )
2009
+
2010
+ # Load region measurements from config file or use defaults
2011
+ if config_json and os.path.isfile(config_json):
2012
+ try:
2013
+ with open(config_json, "r") as f:
2014
+ config_data = json.load(f)
2015
+ # Check if the config has an "aseg" key
2016
+ if "aseg" in config_data:
2017
+ region_measurements = config_data["aseg"]
2018
+ else:
2019
+ region_measurements = config_data
2020
+ except Exception as e:
2021
+ warnings.warn(f"Error loading config file {config_json}: {e}")
2022
+ region_measurements = get_stats_dictionary("aseg")
2023
+ else:
2024
+ region_measurements = get_stats_dictionary("aseg")
2025
+
2026
+ # Dictionary to store the extracted values
2027
+ extracted_values = {}
2028
+
2029
+ # Read the stats file
2030
+ try:
2031
+ with open(stat_file, "r") as file:
2032
+ file_content = file.readlines()
2033
+
2034
+ # Parse the tabular data from the aseg.stats file
2035
+ segmented_data = {}
2036
+ region_data = {}
2037
+
2038
+ # First determine the column positions by looking for TableCol definitions
2039
+ column_indices = {}
2040
+ volume_index = 3 # Default volume index if not specified
2041
+
2042
+ for line in file_content:
2043
+ if line.startswith("# TableCol"):
2044
+ try:
2045
+ parts = line.split()
2046
+ if len(parts) >= 4 and "ColHeader" in line:
2047
+ col_num = int(parts[2]) - 1 # Convert to 0-based index
2048
+ col_name = parts[-1]
2049
+ column_indices[col_name] = col_num
2050
+ if col_name == "Volume_mm3":
2051
+ volume_index = col_num
2052
+ except (ValueError, IndexError) as e:
2053
+ warnings.warn(
2054
+ f"Error parsing TableCol line: {line.strip()}. Error: {e}"
2055
+ )
2056
+
2057
+ # Parse the table rows (lines starting with a number)
2058
+ for line in file_content:
2059
+ if line.strip() and line.strip()[0].isdigit():
2060
+ try:
2061
+ parts = line.strip().split()
2062
+ if len(parts) >= 5:
2063
+ # Structure name is typically in position 4
2064
+ seg_name = parts[4]
2065
+ # Use detected volume index or default to 3
2066
+ vol_idx = (
2067
+ volume_index if 0 <= volume_index < len(parts) else 3
2068
+ )
2069
+ seg_volume = float(parts[vol_idx])
2070
+ segmented_data[seg_name] = seg_volume
2071
+
2072
+ # Also store additional information about the region
2073
+ region_data[seg_name] = {
2074
+ "SegId": int(parts[1]),
2075
+ "NVoxels": int(parts[2]),
2076
+ "Volume": seg_volume,
2077
+ }
2078
+ except Exception as e:
2079
+ warnings.warn(
2080
+ f"Error parsing table line: {line.strip()}. Error: {e}"
2081
+ )
2082
+
2083
+ # Extract values based on the configuration
2084
+ for region_key, region_info in region_measurements.items():
2085
+ value_found = False
2086
+
2087
+ # Get configuration details
2088
+ key = region_info["key"]
2089
+ divisor = region_info.get("divisor", 1000)
2090
+
2091
+ # Try to find in segmented data
2092
+ if key in segmented_data:
2093
+ value = segmented_data[key] / divisor
2094
+ extracted_values[region_key] = [value]
2095
+ value_found = True
2096
+ continue
2097
+
2098
+ # Try any alternate keys from config
2099
+ alt_keys = region_info.get("alternate_keys", [])
2100
+ for alt_key in alt_keys:
2101
+ if alt_key in segmented_data:
2102
+ value = segmented_data[alt_key] / divisor
2103
+ extracted_values[region_key] = [value]
2104
+ value_found = True
2105
+ break
2106
+
2107
+ if value_found:
2108
+ continue
2109
+
2110
+ # Fallback to SegId lookup if provided
2111
+ seg_id = region_info.get("seg_id")
2112
+ if seg_id is not None:
2113
+ for name, data in region_data.items():
2114
+ if data["SegId"] == seg_id:
2115
+ value = data["Volume"] / divisor
2116
+ extracted_values[region_key] = [value]
2117
+ value_found = True
2118
+ break
2119
+
2120
+ # If value not found and we're including missing values
2121
+ if not value_found and include_missing:
2122
+ extracted_values[region_key] = [0.0]
2123
+ warnings.warn(
2124
+ f"Value for {region_key} (key: {key}) not found in {stat_file}"
2125
+ )
2126
+
2127
+ except Exception as e:
2128
+ raise RuntimeError(f"Error reading stats file {stat_file}: {e}")
2129
+
2130
+ # Check if we found any values
2131
+ if not extracted_values:
2132
+ raise ValueError(f"No region measurements found in {stat_file}")
2133
+
2134
+ # Create a dataframe with the values
2135
+ df = pd.DataFrame.from_dict(extracted_values)
2136
+
2137
+ # Format table according to specified type
2138
+ if table_type == "region":
2139
+ # Create region-oriented table
2140
+ df.index = ["Value"]
2141
+ df = df.reset_index()
2142
+ df = df.rename(columns={"index": "Statistics"})
2143
+ else:
2144
+ # Create metric-oriented table
2145
+ df = df.T
2146
+ df.columns = ["Value"]
2147
+
2148
+ # Converting the row names to a new column called statistics
2149
+ df = df.reset_index()
2150
+ df = df.rename(columns={"index": "Region"})
2151
+
2152
+ # Split region names into components
2153
+ reg_names = df["Region"].str.split("-", expand=True)
2154
+
2155
+ # Safely handle region names that might not have 3 components
2156
+ if reg_names.shape[1] >= 3:
2157
+ df.insert(0, "Supraregion", reg_names[0])
2158
+ df.insert(1, "Hemisphere", reg_names[1])
2159
+ elif reg_names.shape[1] == 2:
2160
+ df.insert(0, "Supraregion", reg_names[0])
2161
+ df.insert(1, "Hemisphere", "unknown")
2162
+ else:
2163
+ df.insert(0, "Supraregion", "unknown")
2164
+ df.insert(1, "Hemisphere", "unknown")
2165
+
2166
+ # Add metadata columns
2167
+ nrows = df.shape[0]
2168
+ units = get_units("volume")
2169
+ if isinstance(units, list) and len(units) > 0:
2170
+ units = units[0]
2171
+ elif units is None or (isinstance(units, list) and len(units) == 0):
2172
+ units = "ml"
2173
+
2174
+ df.insert(0, "Source", ["statsfile"] * nrows)
2175
+ df.insert(1, "Metric", ["volume"] * nrows)
2176
+ df.insert(2, "Units", [units] * nrows)
2177
+ df.insert(3, "MetricFile", [stat_file] * nrows)
2178
+
2179
+ # Add BIDS entities if requested
2180
+ if add_bids_entities:
2181
+ try:
2182
+ ent_list = cltbids.entities4table()
2183
+ df_add = cltbids.entities_to_table(
2184
+ filepath=stat_file, entities_to_extract=ent_list
2185
+ )
2186
+ df = cltmisc.expand_and_concatenate(df_add, df)
2187
+ except Exception as e:
2188
+ warnings.warn(f"Could not add BIDS entities: {str(e)}")
2189
+
2190
+ # Save table if requested
2191
+ output_path = None
2192
+ if output_table is not None:
2193
+ output_dir = os.path.dirname(output_table)
2194
+ if output_dir and not os.path.exists(output_dir):
2195
+ raise FileNotFoundError(
2196
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
2197
+ )
2198
+
2199
+ df.to_csv(output_table, sep="\t", index=False)
2200
+ output_path = output_table
2201
+
2202
+ return df, output_path
2203
+
2204
+
2205
+ ####################################################################################################
2206
+ def parse_freesurfer_cortex_stats(
2207
+ stats_file: str,
2208
+ output_table: str = None,
2209
+ table_type: str = "metric",
2210
+ add_bids_entities: bool = True,
2211
+ hemi: str = None,
2212
+ config_json: str = None,
2213
+ include_metrics: list = None,
2214
+ ) -> Tuple[pd.DataFrame, Optional[str]]:
2215
+ """
2216
+ Parse cortical parcellation statistics from a FreeSurfer aparc.stats file.
2217
+
2218
+ This function extracts regional measurements from FreeSurfer's aparc.stats files,
2219
+ including surface area, gray matter volume, cortical thickness, and curvature
2220
+ for each cortical region. It organizes the data into a structured DataFrame.
2221
+
2222
+ Surface area is automatically converted from mm² to cm² (divided by 100)
2223
+ and volume is automatically converted from mm³ to cm³ (divided by 1000).
2224
+
2225
+ Parameters
2226
+ ----------
2227
+ stats_file : str
2228
+ Path to the aparc.stats file generated by FreeSurfer (lh.aparc.stats or rh.aparc.stats).
2229
+ output_table : str, optional
2230
+ Path to save the resulting table. If None, the table is not saved.
2231
+ table_type : str, default="metric"
2232
+ Output format specification:
2233
+ - "metric": Each row represents a region, with columns for different metrics
2234
+ - "region": Each row represents a metric, with columns for different regions
2235
+ add_bids_entities : bool, default=True
2236
+ Whether to include BIDS entities as columns in the resulting DataFrame.
2237
+ This extracts subject, session, and other metadata from the filename.
2238
+ hemi : str, optional
2239
+ Hemisphere identifier ('lh' for left or 'rh' for right). If None, it will be
2240
+ automatically detected from the filename or the file content.
2241
+ config_json : str, optional
2242
+ Path to a JSON configuration file defining cortical metrics to extract.
2243
+ If None, a default configuration will be used.
2244
+ include_metrics : list, optional
2245
+ List of metrics to extract from the stats file. If provided, only these metrics
2246
+ will be extracted from the configuration. If None, all metrics in the configuration
2247
+ will be extracted.
2248
+
2249
+ Returns
2250
+ -------
2251
+ df : pd.DataFrame
2252
+ DataFrame containing the extracted cortical measurements.
2253
+ output_path : str or None
2254
+ Path where the table was saved, or None if no table was saved.
2255
+
2256
+ Examples
2257
+ --------
2258
+ Basic usage with default parameters:
2259
+
2260
+ >>> import os
2261
+ >>> import clabtoolkit.morphometrytools as morpho
2262
+ >>> # Define path to FreeSurfer stats file
2263
+ >>> stats_file = os.path.join('freesurfer', 'sub-01', 'stats', 'lh.aparc.stats')
2264
+ >>> # Parse the stats file
2265
+ >>> df, _ = morpho.parse_freesurfer_cortex_stats(stats_file)
2266
+ >>> # Display the results for thickness
2267
+ >>> thickness_df = df[df['Metric'] == 'thickness']
2268
+ >>> print(thickness_df[['Region', 'Value', 'Std']].head())
2269
+
2270
+ Using a custom configuration file:
2271
+
2272
+ >>> config_file = os.path.join('config', 'stats_mapping.json')
2273
+ >>> df, _ = morpho.parse_freesurfer_cortex_stats(stats_file, config_json=config_file)
2274
+
2275
+ Extract only specific metrics:
2276
+
2277
+ >>> df_area_vol, _ = morpho.parse_freesurfer_cortex_stats(
2278
+ ... stats_file, include_metrics=["area", "volume"]
2279
+ ... )
2280
+ >>> print(df_area_vol['Metric'].unique())
2281
+
2282
+ Using region format (metrics as rows, regions as columns):
2283
+
2284
+ >>> df_region, _ = morpho.parse_freesurfer_cortex_stats(
2285
+ ... stats_file, table_type="region"
2286
+ ... )
2287
+ >>> # View thickness across regions
2288
+ >>> thickness_row = df_region[df_region['Statistics'] == 'thickness']
2289
+ >>> print(thickness_row.iloc[:, :5]) # Print first 5 columns for thickness
2290
+
2291
+ Saving the results to a file:
2292
+
2293
+ >>> output_path = os.path.join('results', 'sub-01_lh_cortical-metrics.tsv')
2294
+ >>> df_saved, saved_path = morpho.parse_freesurfer_cortex_stats(
2295
+ ... stats_file, output_table=output_path
2296
+ ... )
2297
+ >>> print(f"Table saved to: {saved_path}")
2298
+
2299
+ Notes
2300
+ -----
2301
+ This function extracts metrics from aparc.stats files based on the configuration.
2302
+ By default, these include:
2303
+ - Surface area (SurfArea column) in cm² (converted from mm² by dividing by 100)
2304
+ - Gray matter volume (GrayVol column) in cm³ (converted from mm³ by dividing by 1000)
2305
+ - Cortical thickness (ThickAvg column) in mm (unchanged)
2306
+ - Thickness standard deviation (ThickStd column) in mm (unchanged)
2307
+ - Mean curvature (MeanCurv column) in mm⁻¹ (unchanged)
2308
+
2309
+ The function automatically detects the hemisphere from the filename or file content
2310
+ if not specified.
2311
+
2312
+ Cortical parcellation stats files (lh.aparc.stats and rh.aparc.stats) are typically
2313
+ found in the `[subject]/stats/` directory of a FreeSurfer output directory.
2314
+
2315
+ See Also
2316
+ --------
2317
+ parse_freesurfer_global_fromaseg : Parse global volumes from aseg.stats file
2318
+ parse_freesurfer_stats_fromaseg : Parse regional volumes from aseg.stats file
2319
+ """
2320
+
2321
+ # Verify if the file exists
2322
+ if not os.path.isfile(stats_file):
2323
+ raise FileNotFoundError(f"Stats file not found: {stats_file}")
2324
+
2325
+ # Input validation
2326
+ if table_type not in ["region", "metric"]:
2327
+ raise ValueError(
2328
+ f"Invalid table_type: '{table_type}'. Expected 'region' or 'metric'."
2329
+ )
2330
+
2331
+ # Detect hemisphere from filename or file content if not provided
2332
+ if hemi is None:
2333
+ # Try to detect from filename
2334
+ basename = os.path.basename(stats_file)
2335
+ if "lh." in basename:
2336
+ hemi = "lh"
2337
+ elif "rh." in basename:
2338
+ hemi = "rh"
2339
+ else:
2340
+ # Try to detect from file content
2341
+ with open(stats_file, "r") as f:
2342
+ content = f.read()
2343
+ if "# hemi lh" in content:
2344
+ hemi = "lh"
2345
+ elif "# hemi rh" in content:
2346
+ hemi = "rh"
2347
+ else:
2348
+ warnings.warn(
2349
+ f"Could not determine hemisphere from file: {stats_file}. Using 'lh' as default."
2350
+ )
2351
+ hemi = "lh"
2352
+
2353
+ # Load metrics configuration from file or use defaults
2354
+ if config_json and os.path.isfile(config_json):
2355
+ try:
2356
+ with open(config_json, "r") as f:
2357
+ config_data = json.load(f)
2358
+ # Check if the config has a "cortex" key
2359
+ if "cortex" in config_data:
2360
+ metric_mapping = config_data["cortex"]
2361
+ else:
2362
+ metric_mapping = config_data
2363
+
2364
+ # Update unit information in the configuration
2365
+ if (
2366
+ "area" in metric_mapping
2367
+ and metric_mapping["area"].get("unit") == "mm²"
2368
+ ):
2369
+ metric_mapping["area"]["unit"] = "cm²"
2370
+
2371
+ if (
2372
+ "volume" in metric_mapping
2373
+ and metric_mapping["volume"].get("unit") == "mm³"
2374
+ ):
2375
+ metric_mapping["volume"]["unit"] = "cm³"
2376
+
2377
+ except Exception as e:
2378
+ warnings.warn(f"Error loading config file {config_json}: {e}")
2379
+ metric_mapping = get_stats_dictionary("cortex")
2380
+ else:
2381
+ metric_mapping = get_stats_dictionary("cortex")
2382
+
2383
+ # Filter metrics if include_metrics is provided
2384
+ if include_metrics:
2385
+ include_metrics = [m.lower() for m in include_metrics]
2386
+ metric_mapping = {
2387
+ k: v for k, v in metric_mapping.items() if k.lower() in include_metrics
2388
+ }
2389
+
2390
+ # Validate that requested metrics exist
2391
+ if not metric_mapping:
2392
+ raise ValueError(
2393
+ f"None of the requested metrics {include_metrics} found in configuration."
2394
+ )
2395
+
2396
+ # Validate metrics after filtering
2397
+ valid_metrics = list(metric_mapping.keys())
2398
+ if not valid_metrics:
2399
+ raise ValueError("No valid metrics found in configuration.")
2400
+
2401
+ # Read the stats file
2402
+ try:
2403
+ with open(stats_file, "r") as file:
2404
+ lines = file.readlines()
2405
+
2406
+ # Find the data section by looking for column headers
2407
+ col_headers_line = None
2408
+ column_headers = []
2409
+ for i, line in enumerate(lines):
2410
+ if "# ColHeaders" in line:
2411
+ col_headers_line = i
2412
+ column_headers = line.replace("# ColHeaders", "").strip().split()
2413
+ break
2414
+
2415
+ # If column headers not found, try to find the end of the comment section
2416
+ if not column_headers:
2417
+ for i, line in enumerate(lines):
2418
+ if not line.startswith("#") and line.strip():
2419
+ # This might be the start of the data section
2420
+ if i > 0 and "# TableCol" in lines[i - 1]:
2421
+ # The previous line was a table column definition, this is likely the data
2422
+ parts = line.strip().split()
2423
+ if len(parts) >= 7: # Ensure enough columns
2424
+ # Assume a fixed structure based on the example file
2425
+ column_headers = [
2426
+ "StructName",
2427
+ "NumVert",
2428
+ "SurfArea",
2429
+ "GrayVol",
2430
+ "ThickAvg",
2431
+ "ThickStd",
2432
+ "MeanCurv",
2433
+ "GausCurv",
2434
+ "FoldInd",
2435
+ "CurvInd",
2436
+ ]
2437
+ col_headers_line = i - 1
2438
+ print(
2439
+ f"Inferred column headers at line {i}: {column_headers}"
2440
+ )
2441
+ break
2442
+
2443
+ if not column_headers:
2444
+ # Final fallback: manually parse the TableCol definitions
2445
+ table_cols = {}
2446
+ for line in lines:
2447
+ if "# TableCol" in line and "ColHeader" in line:
2448
+ try:
2449
+ parts = line.split()
2450
+ col_num = int(parts[2])
2451
+ col_name = parts[-1]
2452
+ table_cols[col_num] = col_name
2453
+ except (ValueError, IndexError):
2454
+ continue
2455
+
2456
+ if table_cols:
2457
+ # Sort by column number
2458
+ column_headers = [table_cols[i] for i in sorted(table_cols.keys())]
2459
+ print(
2460
+ f"Extracted column headers from TableCol definitions: {column_headers}"
2461
+ )
2462
+
2463
+ if not column_headers:
2464
+ raise ValueError(f"Could not find column headers in {stats_file}")
2465
+
2466
+ # Create column index mapping
2467
+ column_indices = {name: idx for idx, name in enumerate(column_headers)}
2468
+
2469
+ # Determine where to start reading data
2470
+ data_lines = []
2471
+ if col_headers_line is not None:
2472
+ # Start from the line after the column headers
2473
+ data_start = col_headers_line + 1
2474
+ else:
2475
+ # Try to find the first non-comment line
2476
+ data_start = 0
2477
+ for i, line in enumerate(lines):
2478
+ if not line.startswith("#") and line.strip():
2479
+ data_start = i
2480
+ break
2481
+
2482
+ # Extract data lines (non-comment, non-empty lines)
2483
+ for i in range(data_start, len(lines)):
2484
+ line = lines[i]
2485
+ if not line.startswith("#") and line.strip():
2486
+ data_lines.append(line)
2487
+
2488
+ # Now parse the data lines to extract region metrics
2489
+ regions_data = []
2490
+
2491
+ for line in data_lines:
2492
+ parts = line.strip().split()
2493
+ if len(parts) < len(column_headers):
2494
+ print(
2495
+ f"Warning: Line has fewer parts ({len(parts)}) than headers ({len(column_headers)}): {line[:50]}..."
2496
+ )
2497
+ continue
2498
+
2499
+ # Extract region name (first field in most aparc.stats files)
2500
+ region_name = parts[0]
2501
+
2502
+ # Process each metric
2503
+ for metric_name, metric_info in metric_mapping.items():
2504
+ column = metric_info.get("column")
2505
+
2506
+ # Get the column index
2507
+ col_idx = None
2508
+ if column in column_indices:
2509
+ col_idx = column_indices[column]
2510
+ elif "index" in metric_info:
2511
+ # Fallback to index if provided
2512
+ col_idx = int(metric_info["index"])
2513
+ if col_idx >= len(parts):
2514
+ print(
2515
+ f"Warning: Index {col_idx} out of range for line with {len(parts)} parts"
2516
+ )
2517
+ continue
2518
+ else:
2519
+ print(
2520
+ f"Warning: Could not find column {column} for metric {metric_name}"
2521
+ )
2522
+ continue
2523
+
2524
+ # Get metric value
2525
+ try:
2526
+ value = float(parts[col_idx])
2527
+
2528
+ # Apply unit conversions
2529
+ # Convert area from mm² to cm²
2530
+ if metric_name.lower() == "area" or column == "SurfArea":
2531
+ value = value / 100.0 # mm² to cm²
2532
+ # Update the unit in metric_info to reflect conversion
2533
+ if "unit" in metric_info and metric_info["unit"] == "mm²":
2534
+ metric_info["unit"] = "cm²"
2535
+ elif "unit" not in metric_info:
2536
+ metric_info["unit"] = "cm²"
2537
+
2538
+ # Convert volume from mm³ to cm³
2539
+ elif metric_name.lower() == "volume" or column == "GrayVol":
2540
+ value = value / 1000.0 # mm³ to cm³
2541
+ # Update the unit in metric_info to reflect conversion
2542
+ if "unit" in metric_info and metric_info["unit"] == "mm³":
2543
+ metric_info["unit"] = "cm³"
2544
+ elif "unit" not in metric_info:
2545
+ metric_info["unit"] = "cm³"
2546
+
2547
+ # Get standard deviation if applicable
2548
+ std_value = None
2549
+ std_column = metric_info.get("std_index")
2550
+
2551
+ if std_column is not None and std_column not in (
2552
+ None,
2553
+ "null",
2554
+ "None",
2555
+ ):
2556
+ if isinstance(std_column, int) or (
2557
+ isinstance(std_column, str) and std_column.isdigit()
2558
+ ):
2559
+ std_idx = int(std_column)
2560
+ if 0 <= std_idx < len(parts):
2561
+ std_value = float(parts[std_idx])
2562
+ elif (
2563
+ "ThickStd" in column_indices
2564
+ and metric_name.lower() == "thickness"
2565
+ ):
2566
+ std_idx = column_indices["ThickStd"]
2567
+ std_value = float(parts[std_idx])
2568
+
2569
+ # Add to results
2570
+ region_data = {
2571
+ "Region": f"ctx-{hemi}-{region_name}",
2572
+ "Metric": metric_name,
2573
+ "Value": value,
2574
+ "Source": metric_info.get("source", "statsfile"),
2575
+ "Units": metric_info.get("unit", ""),
2576
+ }
2577
+
2578
+ if std_value is not None:
2579
+ region_data["Std"] = std_value
2580
+
2581
+ regions_data.append(region_data)
2582
+ except (IndexError, ValueError) as e:
2583
+ print(f"Error parsing {metric_name} for region {region_name}: {e}")
2584
+
2585
+ # Check if we found any data
2586
+ if not regions_data:
2587
+ print(f"Warning: No data parsed from {len(data_lines)} data lines")
2588
+ # Print a sample of the first data line for debugging
2589
+ if data_lines:
2590
+ print(f"Sample data line: {data_lines[0]}")
2591
+ raise ValueError(f"No cortical parcellation data found in {stats_file}")
2592
+
2593
+ # Create DataFrame
2594
+ df = pd.DataFrame(regions_data)
2595
+
2596
+ # Split region names into components
2597
+ df["Hemisphere"] = hemi
2598
+ df["Supraregion"] = "ctx"
2599
+
2600
+ # Add metadata column for the source file
2601
+ df["MetricFile"] = stats_file
2602
+
2603
+ # Reorder columns
2604
+ column_order = [
2605
+ "Source",
2606
+ "Metric",
2607
+ "Units",
2608
+ "MetricFile",
2609
+ "Supraregion",
2610
+ "Hemisphere",
2611
+ "Region",
2612
+ "Value",
2613
+ ]
2614
+
2615
+ # Add Std if it exists
2616
+ if "Std" in df.columns:
2617
+ column_order.append("Std")
2618
+
2619
+ # Filter columns to those that exist
2620
+ column_order = [col for col in column_order if col in df.columns]
2621
+ df = df[column_order]
2622
+
2623
+ # Format table according to specified type
2624
+ if table_type == "region":
2625
+ # Create region-oriented table (pivot)
2626
+ value_cols = ["Value"]
2627
+ if "Std" in df.columns:
2628
+ value_cols.append("Std")
2629
+
2630
+ pivot_df = pd.pivot_table(
2631
+ df,
2632
+ values=value_cols,
2633
+ index=[
2634
+ "Source",
2635
+ "Metric",
2636
+ "Units",
2637
+ "MetricFile",
2638
+ "Supraregion",
2639
+ "Hemisphere",
2640
+ ],
2641
+ columns="Region",
2642
+ )
2643
+
2644
+ # Flatten the multi-index columns
2645
+ if isinstance(pivot_df.columns, pd.MultiIndex):
2646
+ pivot_df.columns = [
2647
+ f"{col[0]}_{col[1]}" if col[0] != "" else col[1]
2648
+ for col in pivot_df.columns
2649
+ ]
2650
+
2651
+ # Reset index and extract metric as Statistics
2652
+ pivot_df = pivot_df.reset_index()
2653
+ pivot_df = pivot_df.rename(columns={"Metric": "Statistics"})
2654
+
2655
+ # Final DataFrame
2656
+ df = pivot_df
2657
+
2658
+ # Add BIDS entities if requested
2659
+ if add_bids_entities:
2660
+ try:
2661
+ ent_list = cltbids.entities4table()
2662
+ df_add = cltbids.entities_to_table(
2663
+ filepath=stats_file, entities_to_extract=ent_list
2664
+ )
2665
+ df = cltmisc.expand_and_concatenate(df_add, df)
2666
+ except Exception as e:
2667
+ warnings.warn(f"Could not add BIDS entities: {str(e)}")
2668
+
2669
+ # Save table if requested
2670
+ output_path = None
2671
+ if output_table is not None:
2672
+ output_dir = os.path.dirname(output_table)
2673
+ if output_dir and not os.path.exists(output_dir):
2674
+ raise FileNotFoundError(
2675
+ f"Directory does not exist: {output_dir}. Please create the directory before saving."
2676
+ )
2677
+
2678
+ df.to_csv(output_table, sep="\t", index=False)
2679
+ output_path = output_table
2680
+
2681
+ return df, output_path
2682
+
2683
+ except Exception as e:
2684
+ raise RuntimeError(f"Error parsing stats file {stats_file}: {e}")
2685
+
2686
+
2687
+ ####################################################################################################
2688
+ def get_stats_dictionary(region_level: str = "global"):
2689
+ """
2690
+ Return the default global volume measurements configuration for FreeSurfer aseg.stats files.
2691
+
2692
+ Returns
2693
+ -------
2694
+ dict
2695
+ Dictionary containing configuration for extracting global volume measurements.
2696
+ """
2697
+
2698
+ # Get the absolute of this file
2699
+ cwd = os.path.dirname(os.path.abspath(__file__))
2700
+ mapping_stats_json = os.path.join(cwd, "config", "stats_mapping.json")
2701
+
2702
+ with open(mapping_stats_json, encoding="utf-8") as f:
2703
+ mapp_dict = json.load(f)
2704
+
2705
+ return mapp_dict[region_level]
2706
+
2707
+
2708
+ ####################################################################################################
2709
+ ####################################################################################################
2710
+ ############ ############
2711
+ ############ ############
2712
+ ############ Section 5: Methods dedicated to extract metrics from connectivity ############
2713
+ ############ matrices based on graph theory ############
2714
+ ############ ############
2715
+ ############ ############
2716
+ ####################################################################################################
2717
+ ####################################################################################################
2718
+ def network_metrics_to_table(cmat, lut_name, cmat_met):
2719
+
2720
+ if os.path.isfile(lut_name):
2721
+
2722
+ # Reading the colorlut
2723
+ st_codes, st_names, st_colors = cltparc.Parcellation.read_luttable(lut_name)
2724
+ else:
2725
+ # Reading the tsv
2726
+ st_codes, st_names, st_colors = cltparc.Parcellation.read_luttable(tsv_name)
2727
+
2728
+ net_metrics = [
2729
+ "degree",
2730
+ "strength",
2731
+ "clustering_coeff",
2732
+ "betw_centrality",
2733
+ "loc_efficiency",
2734
+ "glob_efficiency",
2735
+ "transitivity",
2736
+ "density_coeff",
2737
+ ]
2738
+ cmat_bin = cmat > 0
2739
+ deg_coeff = bct.degree.degrees_und(cmat_bin)
2740
+ str_coeff = bct.degree.strengths_und(cmat)
2741
+ clu_coeff = bct.clustering_coef_bu(cmat_bin)
2742
+ btw_cent = bct.betweenness_bin(cmat_bin)
2743
+ sho_path = bct.distance_bin(cmat_bin)
2744
+ loc_eff = bct.efficiency_bin(cmat_bin, local=True)
2745
+
2746
+ trans_coeff_g = bct.transitivity_bu(cmat_bin)
2747
+ glob_eff_g = bct.efficiency_bin(cmat_bin)
2748
+ den_coeff_g = bct.density_und(cmat_bin)
2749
+
2750
+ dict_of_cols = {}
2751
+ dict_of_cols["metric"] = ["conn_matrix_" + cmat_met] * len(net_metrics)
2752
+ dict_of_cols["statistics"] = net_metrics
2753
+ dict_of_cols["units"] = ["au"] * len(net_metrics)
2754
+ dict_of_cols["total_brain"] = [""] * (len(net_metrics) - 3) + [
2755
+ glob_eff_g,
2756
+ trans_coeff_g,
2757
+ den_coeff_g[0],
2758
+ ]
2759
+
2760
+ if os.path.isfile(lut_name) or os.path.isfile(tsv_name):
2761
+
2762
+ # Values for each region in the annot
2763
+ nreg = len(st_codes)
2764
+ # outvals = []
2765
+ # outnames = []
2766
+ for i in range(1, nreg + 1):
2767
+ if i < len(cmat):
2768
+ outvals = [
2769
+ deg_coeff[i - 1],
2770
+ str_coeff[i - 1],
2771
+ clu_coeff[i - 1],
2772
+ btw_cent[i - 1],
2773
+ loc_eff[i - 1],
2774
+ ] + [""] * 3
2775
+ else:
2776
+ outvals = [""] * (len(net_metrics)) #
2777
+
2778
+ dict_of_cols[st_names[i - 1]] = outvals
2779
+
2780
+ df = pd.DataFrame.from_dict(dict_of_cols)
2781
+ return df
2782
+
2783
+
2784
+ ####################################################################################################
2785
+ ####################################################################################################
2786
+ ############ ############
2787
+ ############ ############
2788
+ ############ Section 6: Auxiliary methods ############
2789
+ ############ ############
2790
+ ############ ############
2791
+ ####################################################################################################
2792
+ ####################################################################################################
2793
+ def stats_from_vector(metric_vect, stats_list):
2794
+ """
2795
+ Computes specified statistics from a numeric vector.
2796
+
2797
+ This function calculates various statistical measures from a numpy array
2798
+ based on the requested statistics in stats_list.
2799
+
2800
+ Parameters
2801
+ ----------
2802
+ metric_vect : np.ndarray
2803
+ Vector with the values of the metric.
2804
+ stats_list : list
2805
+ List of statistics to compute. Supported values are:
2806
+ 'mean', 'value' (same as 'mean'), 'median', 'std', 'min', 'max'.
2807
+
2808
+ Returns
2809
+ -------
2810
+ list
2811
+ List with the computed statistics in the same order as requested.
2812
+ Values are returned as Python floats, not numpy.float64.
2813
+
2814
+ Raises
2815
+ ------
2816
+ ValueError
2817
+ If an unsupported statistic is requested.
2818
+ TypeError
2819
+ If inputs are not of expected types.
2820
+
2821
+ Examples
2822
+ --------
2823
+ >>> import numpy as np
2824
+ >>> data = np.array([1, 2, 3, 4, 5])
2825
+ >>> stats_from_vector(data, ['mean', 'median', 'std'])
2826
+ [3.0, 3.0, 1.4142135623730951]
2827
+
2828
+ >>> stats_from_vector(data, ['min', 'max'])
2829
+ [1.0, 5.0]
2830
+
2831
+ >>> # Case-insensitive statistic names
2832
+ >>> stats_from_vector(data, ['MEAN', 'Mean', 'mean'])
2833
+ [3.0, 3.0, 3.0]
2834
+
2835
+ >>> # 'value' is an alias for 'mean'
2836
+ >>> stats_from_vector(data, ['mean', 'value'])
2837
+ [3.0, 3.0]
2838
+
2839
+ >>> # Empty arrays return NaN for all statistics
2840
+ >>> stats_from_vector(np.array([]), ['mean', 'median'])
2841
+ [nan, nan]
2842
+
2843
+ >>> # Error on unsupported statistic
2844
+ >>> stats_from_vector(data, ['mean', 'mode'])
2845
+ Traceback (most recent call last):
2846
+ ...
2847
+ ValueError: Unsupported statistics: mode
2848
+ """
2849
+ if not isinstance(stats_list, (list, tuple)):
2850
+ raise TypeError("stats_list must be a list or tuple")
2851
+
2852
+ if len(metric_vect) == 0:
2853
+ return [float("nan")] * len(stats_list)
2854
+
2855
+ # Map of statistic names to their computation functions
2856
+ stats_map = {
2857
+ "mean": np.mean,
2858
+ "value": np.mean,
2859
+ "median": np.median,
2860
+ "std": np.std,
2861
+ "min": np.min,
2862
+ "max": np.max,
2863
+ }
2864
+
2865
+ # Convert all stats to lowercase for case-insensitive matching
2866
+ lowercase_stats = [s.lower() for s in stats_list]
2867
+
2868
+ # Check for unsupported statistics
2869
+ unsupported = [s for s in lowercase_stats if s not in stats_map]
2870
+ if unsupported:
2871
+ raise ValueError(f"Unsupported statistics: {', '.join(unsupported)}")
2872
+
2873
+ # Compute all requested statistics and convert to native Python float
2874
+ return [float(stats_map[stat](metric_vect)) for stat in lowercase_stats]
2875
+
2876
+
2877
+ ####################################################################################################
2878
+ def get_units(
2879
+ metrics: Union[str, List[str]], metrics_json: Optional[Union[str, Dict]] = None
2880
+ ) -> List[str]:
2881
+ """
2882
+ Get the units associated with specified metrics.
2883
+
2884
+ Retrieves the corresponding units for one or more metrics from either a provided
2885
+ JSON file, dictionary, or the default configuration.
2886
+
2887
+ Parameters
2888
+ ----------
2889
+ metrics : str or list of str
2890
+ Name(s) of the metrics. Can be a single metric as a string or multiple metrics as a list.
2891
+
2892
+ metrics_json : str or dict, optional
2893
+ Either:
2894
+ - Path to a JSON file containing the metrics units dictionary
2895
+ - Dictionary directly containing the metrics units mapping
2896
+ - None (default), which uses the package's built-in configuration
2897
+
2898
+ Returns
2899
+ -------
2900
+ list of str
2901
+ Units corresponding to each requested metric. Returns "unknown" for any metric
2902
+ not found in the dictionary.
2903
+
2904
+ Raises
2905
+ ------
2906
+ ValueError
2907
+ If the provided JSON file path is invalid or the metrics_json structure is incorrect.
2908
+
2909
+ Examples
2910
+ --------
2911
+ >>> import clabtoolkit.morphometrytools as clmorphtools
2912
+ >>> # Get unit for a single metric
2913
+ >>> clmorphtools.get_units('thickness')
2914
+ ['mm']
2915
+ >>>
2916
+ >>> # Get units for multiple metrics
2917
+ >>> clmorphtools.get_units(['thickness', 'area', 'volume'])
2918
+ ['mm', 'cm²', 'cm³']
2919
+ >>>
2920
+ >>> # Using a custom metrics dictionary
2921
+ >>> custom_dict = {"metrics_units": {"custom_metric": "kg"}}
2922
+ >>> clmorphtools.get_units('custom_metric', metrics_json=custom_dict)
2923
+ ['kg']
2924
+ >>>
2925
+ >>> # Handling unknown metrics
2926
+ >>> clmorphtools.get_units(['thickness', 'unknown_metric'])
2927
+ ['mm', 'unknown']
2928
+ """
2929
+ # Convert single metric to list for uniform processing
2930
+ if isinstance(metrics, str):
2931
+ metrics = [metrics]
2932
+
2933
+ # Get metrics dictionary from appropriate source
2934
+ if metrics_json is None:
2935
+ # Use default configuration
2936
+ config_path = os.path.join(os.path.dirname(__file__), "config", "config.json")
2937
+ try:
2938
+ with open(config_path, "r", encoding="utf-8") as f:
2939
+ config_data = json.load(f)
2940
+ metric_dict = config_data.get("metrics_units", {})
2941
+ except (FileNotFoundError, json.JSONDecodeError) as e:
2942
+ raise ValueError(f"Error loading default configuration: {str(e)}")
2943
+ elif isinstance(metrics_json, str):
2944
+ # Load from provided JSON file path
2945
+ if not os.path.isfile(metrics_json):
2946
+ raise ValueError(f"Invalid JSON file path: {metrics_json}")
2947
+ try:
2948
+ with open(metrics_json, "r") as f:
2949
+ config_data = json.load(f)
2950
+ metric_dict = config_data.get("metrics_units", {})
2951
+ if not metric_dict:
2952
+ raise ValueError(
2953
+ "Missing 'metrics_units' key in the provided JSON file"
2954
+ )
2955
+ except json.JSONDecodeError:
2956
+ raise ValueError(f"Invalid JSON format in file: {metrics_json}")
2957
+ elif isinstance(metrics_json, dict):
2958
+ # Use provided dictionary
2959
+ metric_dict = metrics_json.get("metrics_units", metrics_json)
2960
+ else:
2961
+ raise ValueError("metrics_json must be a file path, dictionary, or None")
2962
+
2963
+ # Create a case-insensitive lookup dictionary (only once)
2964
+ lookup_dict = {k.lower(): v for k, v in metric_dict.items()}
2965
+
2966
+ # Lookup units for each metric
2967
+ return [lookup_dict.get(metric.lower(), "unknown") for metric in metrics]
2968
+
2969
+
2970
+ ####################################################################################################