clabtoolkit 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clabtoolkit/__init__.py +5 -0
- clabtoolkit/bidstools.py +2260 -0
- clabtoolkit/config/bids.json +147 -0
- clabtoolkit/config/config.json +58 -0
- clabtoolkit/config/lobes.json +112 -0
- clabtoolkit/config/stats_mapping.json +140 -0
- clabtoolkit/config/viz_views.json +268 -0
- clabtoolkit/connectivitytools.py +0 -0
- clabtoolkit/dicomtools.py +941 -0
- clabtoolkit/dwitools.py +1999 -0
- clabtoolkit/freesurfertools.py +5378 -0
- clabtoolkit/imagetools.py +2734 -0
- clabtoolkit/misctools.py +5204 -0
- clabtoolkit/morphometrytools.py +2970 -0
- clabtoolkit/networktools.py +351 -0
- clabtoolkit/parcellationtools.py +3239 -0
- clabtoolkit/pipelinetools.py +1187 -0
- clabtoolkit/plottools.py +682 -0
- clabtoolkit/qcqatools.py +1305 -0
- clabtoolkit/segmentationtools.py +356 -0
- clabtoolkit/surfacetools.py +2785 -0
- clabtoolkit/visualizationtools.py +5575 -0
- clabtoolkit-0.3.3.dist-info/METADATA +212 -0
- clabtoolkit-0.3.3.dist-info/RECORD +28 -0
- clabtoolkit-0.3.3.dist-info/WHEEL +5 -0
- clabtoolkit-0.3.3.dist-info/licenses/AUTHORS.rst +13 -0
- clabtoolkit-0.3.3.dist-info/licenses/LICENSE +16 -0
- clabtoolkit-0.3.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
####################################################################################################
|