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