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
clabtoolkit/dwitools.py
ADDED
|
@@ -0,0 +1,1999 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import numpy as np
|
|
3
|
+
import warnings
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Tuple, Any, Optional
|
|
6
|
+
from scipy.interpolate import RegularGridInterpolator
|
|
7
|
+
|
|
8
|
+
import nibabel as nib
|
|
9
|
+
from nibabel.streamlines import Field, ArraySequence
|
|
10
|
+
from nibabel.streamlines.trk import TrkFile
|
|
11
|
+
from nibabel.orientations import aff2axcodes
|
|
12
|
+
|
|
13
|
+
from skimage import measure
|
|
14
|
+
from typing import Union, Dict, List
|
|
15
|
+
from dipy.segment.clustering import QuickBundlesX, QuickBundles
|
|
16
|
+
from dipy.tracking.streamline import set_number_of_points
|
|
17
|
+
from dipy.io.streamline import save_trk
|
|
18
|
+
from dipy.io.stateful_tractogram import StatefulTractogram, Space
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# add progress bar using rich progress bar
|
|
22
|
+
from rich.progress import Progress, TextColumn, BarColumn, SpinnerColumn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Importing the internal modules
|
|
26
|
+
from . import misctools as cltmisc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
####################################################################################################
|
|
30
|
+
####################################################################################################
|
|
31
|
+
############ ############
|
|
32
|
+
############ ############
|
|
33
|
+
############ Section 1: Methods to work with DWI images ############
|
|
34
|
+
############ ############
|
|
35
|
+
############ ############
|
|
36
|
+
####################################################################################################
|
|
37
|
+
####################################################################################################
|
|
38
|
+
def delete_dwi_volumes(
|
|
39
|
+
in_image: str,
|
|
40
|
+
bvec_file: str = None,
|
|
41
|
+
bval_file: str = None,
|
|
42
|
+
out_image: str = None,
|
|
43
|
+
bvals_to_delete: Union[int, List[Union[int, tuple, list, str, np.ndarray]]] = None,
|
|
44
|
+
vols_to_delete: Union[int, List[Union[int, tuple, list, str, np.ndarray]]] = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Remove specific volumes from DWI image. If no volumes are specified, the function will remove the last B0s of the DWI image.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
in_image : str
|
|
52
|
+
Path to the diffusion weighted image file.
|
|
53
|
+
|
|
54
|
+
bvec_file : str, optional
|
|
55
|
+
Path to the bvec file. If None, it will assume the bvec file is in the same directory as the DWI file with the same name but with the .bvec extension.
|
|
56
|
+
|
|
57
|
+
bval_file : str, optional
|
|
58
|
+
Path to the bval file. If None, it will assume the bval file is in the same directory as the DWI file with the same name but with the .bval extension.
|
|
59
|
+
|
|
60
|
+
out_image : str, optional
|
|
61
|
+
Path to the output file. If None, it will assume the output file is in the same directory as the DWI file with the same name but with the .nii.gz extension.
|
|
62
|
+
The original file will be overwritten if the output file is not specified.
|
|
63
|
+
|
|
64
|
+
bvals_to_delete : int, list, optional
|
|
65
|
+
List of bvals to delete. If None, it will assume the bvals to delete are the last B0s of the DWI image.
|
|
66
|
+
Some conditions could be used to delete the volumes.
|
|
67
|
+
For example:
|
|
68
|
+
1. If you want to delete all the volumes with bval = 0, you can use:
|
|
69
|
+
bvals_to_delete = [0]
|
|
70
|
+
|
|
71
|
+
2. If you want to delete all the volumes with b-values higher than 1000, you can use:
|
|
72
|
+
bvals_to_delete = [bvals > 1000] or bvals_to_delete = [bvals >= 1000] if you want to include the 1000 bvals.
|
|
73
|
+
|
|
74
|
+
3. If you want to delete all the volumes with b-values between 1000 and 3000 you can use:
|
|
75
|
+
bvals_to_delete = [1000 < bvals < 3000] or bvals_to_delete = [1000 <= bvals < 3000] if you want to include the 1000 but not the 3000 bvals.
|
|
76
|
+
|
|
77
|
+
For more complex conditions, you can see the function get_indices_by_condition. Included in the clabtoolkit.misctools module.
|
|
78
|
+
|
|
79
|
+
vols_to_delete : int, list, optional
|
|
80
|
+
Indices of the volumes to delete. If None, it will assume the volumes to delete are the last B0s of the DWI image.
|
|
81
|
+
Some conditions could be used to delete the volumes.
|
|
82
|
+
For example:
|
|
83
|
+
1. If you want to delete the first 3 volumes, you can use:
|
|
84
|
+
vols_to_delete = [0, 1, 2]
|
|
85
|
+
|
|
86
|
+
2. If you want to delete the volumes from 0 to 10, you can use:
|
|
87
|
+
vols_to_delete = [0:10] or vols_to_delete = [0-10]
|
|
88
|
+
|
|
89
|
+
3. If you want to delete the volumes from 0 to 10 and 20 to 30, you can use:
|
|
90
|
+
vols_to_delete = [0:10, 20:30] or vols_to_delete = [0-10, 20-30]
|
|
91
|
+
|
|
92
|
+
4. If you want to delete the volumes from 0 to 10 and the volumes 40 and 60, you can use:
|
|
93
|
+
vols_to_delete = [0:10, 40, 60] or vols_to_delete = [0-10, 40, 60] or vols_to_delete = ['0-10, 40, 60'], etc
|
|
94
|
+
|
|
95
|
+
For more complex conditions, you can see the function build_indices. Included in the clabtoolkit.misctools module.
|
|
96
|
+
|
|
97
|
+
If both bvals_to_delete and vols_to_delete are specified, the function will remove the volumes with the bvals specified
|
|
98
|
+
and the volumes specified in the vols_to_delete list.
|
|
99
|
+
The function will unify all the indices in a single list and remove the volumes from the DWI image.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
out_image : str
|
|
104
|
+
Path to the diffusion weighted image file.
|
|
105
|
+
|
|
106
|
+
out_bvecs_file : str
|
|
107
|
+
Path to the bvec file. If None, it will assume the bvec file is in the same directory as the DWI file with the same name but with the .bvec extension.
|
|
108
|
+
|
|
109
|
+
out_bvals_file : str
|
|
110
|
+
Path to the bval file. If None, it will assume the bval file is in the same directory as the DWI file with the same name but with the .bval extension.
|
|
111
|
+
|
|
112
|
+
vols2rem : list
|
|
113
|
+
List of volumes removed.
|
|
114
|
+
|
|
115
|
+
Notes
|
|
116
|
+
-----
|
|
117
|
+
IMPORTANT: The function will overwrite the original DWI file if the output file is not specified.
|
|
118
|
+
IMPORTANT: The function will overwrite the original bvec and bval files if the output file is not specified.
|
|
119
|
+
IMPORTANT: The function will remove the last B0s of the DWI image if no volumes are specified.
|
|
120
|
+
|
|
121
|
+
Examples
|
|
122
|
+
-----------
|
|
123
|
+
|
|
124
|
+
>>> delete_volumes('dwi.nii.gz') # will remove the last B0s. The original file will be overwritten.
|
|
125
|
+
|
|
126
|
+
>>> delete_volumes('dwi.nii.gz', out_image='dwi_clean.nii.gz') # will remove the last B0s and save the output in dwi_clean.nii.gz
|
|
127
|
+
|
|
128
|
+
>>> delete_volumes('dwi.nii.gz', vols_to_delete=[0, 1, 2]) # will remove the first 3 volumes
|
|
129
|
+
|
|
130
|
+
>>> delete_volumes('dwi.nii.gz', bvec_file='dwi.bvec', bval_file='dwi.bval') # will remove the last B0s and it will assume the bvec and bval files are in the same directory as the DWI file.
|
|
131
|
+
|
|
132
|
+
>>> delete_volumes('dwi.nii.gz', bvec_file='dwi.bvec', bval_file='dwi.bval', bvals_to_delete= [3000, "bvals >=5000"], out_image='dwi_clean.nii.gz') # will remove the volumes with bvals equal to 3000 and equal or higher than 5000.
|
|
133
|
+
The output will be saved in in dwi_clean.nii.gz
|
|
134
|
+
IMPORTANT: the b-values file dwi.bval should be in the same directory as the DWI file.
|
|
135
|
+
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
# Creating the name for the json file
|
|
139
|
+
if os.path.isfile(in_image):
|
|
140
|
+
pth = os.path.dirname(in_image)
|
|
141
|
+
fname = os.path.basename(in_image)
|
|
142
|
+
else:
|
|
143
|
+
raise FileNotFoundError(f"File {in_image} not found.")
|
|
144
|
+
|
|
145
|
+
if fname.endswith(".nii.gz"):
|
|
146
|
+
flname = fname[0:-7]
|
|
147
|
+
elif fname.endswith(".nii"):
|
|
148
|
+
flname = fname[0:-4]
|
|
149
|
+
|
|
150
|
+
# Checking if the file exists. If it is None assume it is in the same directory with the same name as the DWI file but with the .bvec extensions.
|
|
151
|
+
if bvec_file is None:
|
|
152
|
+
bvec_file = os.path.join(pth, flname + ".bvec")
|
|
153
|
+
|
|
154
|
+
# Checking if the file exists. If it is None assume it is in the same directory with the same name as the DWI file but with the .bval extensions.
|
|
155
|
+
if bval_file is None:
|
|
156
|
+
bval_file = os.path.join(pth, flname + ".bval")
|
|
157
|
+
|
|
158
|
+
# Checking the ouput basename
|
|
159
|
+
if out_image is not None:
|
|
160
|
+
fl_out_name = os.path.basename(out_image)
|
|
161
|
+
|
|
162
|
+
if fl_out_name.endswith(".nii.gz"):
|
|
163
|
+
fl_out_name = fl_out_name[0:-7]
|
|
164
|
+
elif fl_out_name.endswith(".nii"):
|
|
165
|
+
fl_out_name = fl_out_name[0:-4]
|
|
166
|
+
|
|
167
|
+
fl_out_path = os.path.dirname(out_image)
|
|
168
|
+
|
|
169
|
+
if not os.path.isdir(fl_out_path):
|
|
170
|
+
raise FileNotFoundError(f"Output path {fl_out_path} does not exist.")
|
|
171
|
+
else:
|
|
172
|
+
fl_out_name = fname
|
|
173
|
+
fl_out_path = pth
|
|
174
|
+
|
|
175
|
+
# Checking the volumes to delete
|
|
176
|
+
if vols_to_delete is not None:
|
|
177
|
+
if not isinstance(vols_to_delete, list):
|
|
178
|
+
vols_to_delete = [vols_to_delete]
|
|
179
|
+
|
|
180
|
+
vols_to_delete = cltmisc.build_indices(vols_to_delete, nonzeros=False)
|
|
181
|
+
|
|
182
|
+
# Checking the bvals to delete. This variable will overwrite the vols_to_delete variable if it is not None.
|
|
183
|
+
if bvals_to_delete is not None:
|
|
184
|
+
if not isinstance(bvals_to_delete, list):
|
|
185
|
+
bvals_to_delete = [bvals_to_delete]
|
|
186
|
+
|
|
187
|
+
# Loading bvalues
|
|
188
|
+
if os.path.exists(bval_file):
|
|
189
|
+
bvals = np.loadtxt(bval_file, dtype=float, max_rows=5).astype(int)
|
|
190
|
+
|
|
191
|
+
tmp_bvals = cltmisc.build_values_with_conditions(
|
|
192
|
+
bvals_to_delete, bvals=bvals, nonzeros=False
|
|
193
|
+
)
|
|
194
|
+
tmp_bvals_to_delete = np.where(np.isin(bvals, tmp_bvals))[0]
|
|
195
|
+
|
|
196
|
+
if vols_to_delete is not None:
|
|
197
|
+
vols_to_delete += tmp_bvals_to_delete.tolist()
|
|
198
|
+
|
|
199
|
+
# Remove duplicates
|
|
200
|
+
vols_to_delete = list(set(vols_to_delete))
|
|
201
|
+
|
|
202
|
+
else:
|
|
203
|
+
vols_to_delete = tmp_bvals_to_delete
|
|
204
|
+
|
|
205
|
+
if vols_to_delete is not None:
|
|
206
|
+
# check if vols_to_delete is not empty
|
|
207
|
+
if len(vols_to_delete) == 0:
|
|
208
|
+
print(f"No volumes to delete. The volumes to delete are empty.")
|
|
209
|
+
return in_image
|
|
210
|
+
|
|
211
|
+
# Loading the DWI image
|
|
212
|
+
mapI = nib.load(in_image)
|
|
213
|
+
|
|
214
|
+
# getting the dimensions of the image
|
|
215
|
+
dim = mapI.shape
|
|
216
|
+
# Only remove the volumes is the image is 4D
|
|
217
|
+
|
|
218
|
+
if len(dim) == 4:
|
|
219
|
+
# Getting the number of volumes
|
|
220
|
+
nvols = dim[3]
|
|
221
|
+
|
|
222
|
+
if vols_to_delete is not None:
|
|
223
|
+
|
|
224
|
+
if len(vols_to_delete) == nvols:
|
|
225
|
+
# If the number of volumes to delete is equal to the number of volumes, send a warning and return the original file
|
|
226
|
+
print(
|
|
227
|
+
f"Number of volumes to delete is equal to the number of volumes. No volumes will be deleted."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return in_image
|
|
231
|
+
|
|
232
|
+
# Check if the volumes to delete are in the range of the number of volumes
|
|
233
|
+
if np.max(vols_to_delete) >= nvols:
|
|
234
|
+
# Detect which values of the list vols_to_delete are out of range
|
|
235
|
+
|
|
236
|
+
# Convert the list to a numpy array
|
|
237
|
+
vols_to_delete = np.array(vols_to_delete)
|
|
238
|
+
|
|
239
|
+
# Check if the values are out of range
|
|
240
|
+
out_of_range = np.where(vols_to_delete >= nvols)[0]
|
|
241
|
+
# Raise an error with the out of range values
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"Volumes out of the range: {vols_to_delete[out_of_range]} . The values should be between 0 and {nvols-1}."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Check if the volumes to delete are in the range of the number of volumes
|
|
247
|
+
if np.min(vols_to_delete) < 0:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
f"Volumes to delete {vols_to_delete} are out of range. The values shoudl be between 0 and {nvols-1}."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
vols2rem = np.where(np.isin(np.arange(nvols), vols_to_delete))[0]
|
|
253
|
+
vols2keep = np.where(
|
|
254
|
+
np.isin(np.arange(nvols), vols_to_delete, invert=True)
|
|
255
|
+
)[0]
|
|
256
|
+
else:
|
|
257
|
+
|
|
258
|
+
# Loading bvalues
|
|
259
|
+
if os.path.exists(bval_file):
|
|
260
|
+
bvals = np.loadtxt(bval_file, dtype=float, max_rows=5).astype(int)
|
|
261
|
+
|
|
262
|
+
mask = bvals < 10
|
|
263
|
+
lb_bvals = measure.label(mask, 2)
|
|
264
|
+
|
|
265
|
+
if np.max(lb_bvals) > 1 and lb_bvals[-1] != 0:
|
|
266
|
+
|
|
267
|
+
# Removing the last cluster of B0s
|
|
268
|
+
lab2rem = lb_bvals[-1]
|
|
269
|
+
vols2rem = np.where(lb_bvals == lab2rem)[0]
|
|
270
|
+
vols2keep = np.where(lb_bvals != lab2rem)[0]
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
# Exit the function if there are no B0s to remove at the end of the volume. Leave a message.
|
|
274
|
+
print("No B0s to remove at the end of the volume.")
|
|
275
|
+
|
|
276
|
+
return in_image
|
|
277
|
+
else:
|
|
278
|
+
raise FileNotFoundError(
|
|
279
|
+
f"File {bval_file} not found. It is mandatory if the volumes to remove are not specified (vols_to_delete)."
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
diffData = mapI.get_fdata()
|
|
283
|
+
affine = mapI.affine
|
|
284
|
+
|
|
285
|
+
# Removing the volumes
|
|
286
|
+
array_data = np.delete(diffData, vols2rem, 3)
|
|
287
|
+
|
|
288
|
+
# Temporal image and diffusion scheme
|
|
289
|
+
array_img = nib.Nifti1Image(array_data, affine)
|
|
290
|
+
nib.save(array_img, out_image)
|
|
291
|
+
|
|
292
|
+
# Saving new bvecs and new bvals
|
|
293
|
+
if os.path.isfile(bvec_file):
|
|
294
|
+
bvecs = np.loadtxt(bvec_file, dtype=float)
|
|
295
|
+
if bvecs.shape[0] == 3:
|
|
296
|
+
select_bvecs = bvecs[:, vols2keep]
|
|
297
|
+
else:
|
|
298
|
+
select_bvecs = bvecs[vols2keep, :]
|
|
299
|
+
|
|
300
|
+
select_bvecs.transpose()
|
|
301
|
+
if out_image.endswith("nii.gz"):
|
|
302
|
+
out_bvecs_file = out_image.replace(".nii.gz", ".bvec")
|
|
303
|
+
elif out_image.endswith("nii"):
|
|
304
|
+
out_bvecs_file = out_image.replace(".nii", ".bvec")
|
|
305
|
+
|
|
306
|
+
np.savetxt(out_bvecs_file, select_bvecs, fmt="%f")
|
|
307
|
+
else:
|
|
308
|
+
out_bvecs_file = None
|
|
309
|
+
|
|
310
|
+
# Saving new bvals
|
|
311
|
+
if os.path.isfile(bval_file):
|
|
312
|
+
bvals = np.loadtxt(bval_file, dtype=float, max_rows=5).astype(int)
|
|
313
|
+
select_bvals = bvals[vols2keep]
|
|
314
|
+
select_bvals.transpose()
|
|
315
|
+
|
|
316
|
+
if out_image.endswith("nii.gz"):
|
|
317
|
+
out_bvals_file = out_image.replace(".nii.gz", ".bval")
|
|
318
|
+
elif out_image.endswith("nii"):
|
|
319
|
+
out_bvals_file = out_image.replace(".nii", ".bval")
|
|
320
|
+
np.savetxt(out_bvals_file, select_bvals, newline=" ", fmt="%d")
|
|
321
|
+
else:
|
|
322
|
+
out_bvals_file = None
|
|
323
|
+
|
|
324
|
+
else:
|
|
325
|
+
vols2rem = None
|
|
326
|
+
raise Warning(f"Image {in_image} is not a 4D image. No volumes to remove.")
|
|
327
|
+
|
|
328
|
+
return out_image, out_bvecs_file, out_bvals_file, vols2rem
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
####################################################################################################
|
|
332
|
+
def get_b0s(
|
|
333
|
+
dwi_img: str, b0s_img: str, bval_file: str = None, bval_thresh: int = 0
|
|
334
|
+
) -> str:
|
|
335
|
+
"""
|
|
336
|
+
Extract B0 volumes from a DWI image and save them as a separate NIfTI file.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
dwi_img : str
|
|
341
|
+
Path to the input DWI image file.
|
|
342
|
+
|
|
343
|
+
b0s_img : str
|
|
344
|
+
Path to the output B0 image file.
|
|
345
|
+
|
|
346
|
+
bval_file : str, optional
|
|
347
|
+
Path to the bval file. If None, it will assume the bval file is in the same directory as the DWI file with the same name but with the .bval extension.
|
|
348
|
+
The bval file is used to identify the B0 volumes in the DWI image.
|
|
349
|
+
|
|
350
|
+
bval_thresh : int, optional
|
|
351
|
+
Threshold for identifying B0 volumes. Default is 0. Volumes with b-values below this threshold will be considered B0 volumes.
|
|
352
|
+
|
|
353
|
+
Returns
|
|
354
|
+
-------
|
|
355
|
+
b0s_img : str
|
|
356
|
+
Path to the output B0 image file.
|
|
357
|
+
|
|
358
|
+
b0_vols : List[int]
|
|
359
|
+
List of indices of the B0 volumes extracted from the DWI image.
|
|
360
|
+
|
|
361
|
+
Raises
|
|
362
|
+
------
|
|
363
|
+
FileNotFoundError
|
|
364
|
+
If the input DWI image file or the bval file does not exist.
|
|
365
|
+
|
|
366
|
+
ValueError
|
|
367
|
+
If the output path for the B0 image file does not exist.
|
|
368
|
+
|
|
369
|
+
Examples
|
|
370
|
+
-----------
|
|
371
|
+
|
|
372
|
+
>>> dwi_img = 'path/to/dwi_image.nii.gz'
|
|
373
|
+
>>> b0s_img = 'path/to/b0_image.nii.gz'
|
|
374
|
+
>>> bval_file = 'path/to/bvals.bval'
|
|
375
|
+
>>> b0s_img, b0_vols = get_b0s(dwi_img, b0s_img, bval_file)
|
|
376
|
+
>>> print(f"B0 image saved at: {b0s_img}")
|
|
377
|
+
>>> print(f"B0 volumes indices: {b0_vols}")
|
|
378
|
+
|
|
379
|
+
>>> b0s_img, b0_vols = get_b0s(dwi_img, b0s_img, bval_file, bval_thresh=10)
|
|
380
|
+
>>> print(f"B0 image saved at: {b0s_img}")
|
|
381
|
+
>>> print(f"B0 volumes indices: {b0_vols}")
|
|
382
|
+
>>> All the volumes with b-values below 10 will be considered B0 volumes.
|
|
383
|
+
|
|
384
|
+
>>> b0s_img, b0_vols = get_b0s(dwi_img, b0s_img)
|
|
385
|
+
>>> print(f"B0 image saved at: {b0s_img}")
|
|
386
|
+
>>> print(f"B0 volumes indices: {b0_vols}")
|
|
387
|
+
>>> The bval file will be assumed to be in the same directory as the DWI file with the same name but with the .bval extension.
|
|
388
|
+
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
# Creating the name for the json file
|
|
392
|
+
if os.path.isfile(dwi_img):
|
|
393
|
+
pth = os.path.dirname(dwi_img)
|
|
394
|
+
fname = os.path.basename(dwi_img)
|
|
395
|
+
else:
|
|
396
|
+
raise FileNotFoundError(f"File {dwi_img} not found.")
|
|
397
|
+
|
|
398
|
+
if fname.endswith(".nii.gz"):
|
|
399
|
+
flname = fname[0:-7]
|
|
400
|
+
elif fname.endswith(".nii"):
|
|
401
|
+
flname = fname[0:-4]
|
|
402
|
+
|
|
403
|
+
# Checking if the file exists. If it is None assume it is in the same directory with the same name as the DWI file but with the .bval extensions.
|
|
404
|
+
if bval_file is None:
|
|
405
|
+
bval_file = os.path.join(pth, flname + ".bval")
|
|
406
|
+
|
|
407
|
+
# Checking the ouput basename
|
|
408
|
+
if b0s_img is not None:
|
|
409
|
+
fl_out_name = os.path.basename(b0s_img)
|
|
410
|
+
|
|
411
|
+
if fl_out_name.endswith(".nii.gz"):
|
|
412
|
+
fl_out_name = fl_out_name[0:-7]
|
|
413
|
+
elif fl_out_name.endswith(".nii"):
|
|
414
|
+
fl_out_name = fl_out_name[0:-4]
|
|
415
|
+
|
|
416
|
+
fl_out_path = os.path.dirname(b0s_img)
|
|
417
|
+
|
|
418
|
+
if not os.path.isdir(fl_out_path):
|
|
419
|
+
raise FileNotFoundError(f"Output path {fl_out_path} does not exist.")
|
|
420
|
+
else:
|
|
421
|
+
fl_out_name = fname
|
|
422
|
+
fl_out_path = pth
|
|
423
|
+
|
|
424
|
+
# Loading bvalues
|
|
425
|
+
if os.path.exists(bval_file):
|
|
426
|
+
bvals = np.loadtxt(bval_file, dtype=float, max_rows=5).astype(int)
|
|
427
|
+
|
|
428
|
+
# Generate search cad
|
|
429
|
+
cad = ["bvals > " + str(bval_thresh)]
|
|
430
|
+
|
|
431
|
+
# Get the indices of the volumes that will be removed
|
|
432
|
+
vols2rem = cltmisc.build_indices_with_conditions(
|
|
433
|
+
cad, bvals=bvals, nonzeros=False
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
b0_vols = np.setdiff1d(np.arange(bvals.shape[0]), vols2rem)
|
|
437
|
+
|
|
438
|
+
if len(vols2rem) == 0:
|
|
439
|
+
print(f"No B0s to remove. The volumes to delete are empty.")
|
|
440
|
+
return dwi_img
|
|
441
|
+
else:
|
|
442
|
+
|
|
443
|
+
mapI = nib.load(dwi_img)
|
|
444
|
+
diffData = mapI.get_fdata()
|
|
445
|
+
affine = mapI.affine
|
|
446
|
+
|
|
447
|
+
# Removing the volumes
|
|
448
|
+
array_data = np.delete(diffData, vols2rem, 3)
|
|
449
|
+
|
|
450
|
+
# Temporal image and diffusion scheme
|
|
451
|
+
array_img = nib.Nifti1Image(array_data, affine)
|
|
452
|
+
nib.save(array_img, b0s_img)
|
|
453
|
+
|
|
454
|
+
return b0s_img, b0_vols
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
####################################################################################################
|
|
458
|
+
####################################################################################################
|
|
459
|
+
############ ############
|
|
460
|
+
############ ############
|
|
461
|
+
############ Section 2: Methods to work with streamlines ############
|
|
462
|
+
############ ############
|
|
463
|
+
############ ############
|
|
464
|
+
####################################################################################################
|
|
465
|
+
####################################################################################################
|
|
466
|
+
def tck2trk(
|
|
467
|
+
in_tract: str, ref_img: str, out_tract: str = None, force: bool = False
|
|
468
|
+
) -> str:
|
|
469
|
+
"""
|
|
470
|
+
Convert a TCK file to a TRK file using a reference image for the header.
|
|
471
|
+
|
|
472
|
+
Parameters
|
|
473
|
+
----------
|
|
474
|
+
in_tract : str
|
|
475
|
+
Path to the input TCK file.
|
|
476
|
+
|
|
477
|
+
ref_img : str
|
|
478
|
+
Path to the reference NIfTI image for creating the TRK header.
|
|
479
|
+
|
|
480
|
+
out_tract : str, optional
|
|
481
|
+
Path for the output TRK file. Defaults to replacing the .tck extension with .trk.
|
|
482
|
+
|
|
483
|
+
force : bool, optional
|
|
484
|
+
If True, overwrite the output file if it exists. Defaults to False.
|
|
485
|
+
|
|
486
|
+
Returns
|
|
487
|
+
-------
|
|
488
|
+
str
|
|
489
|
+
Path to the output TRK file.
|
|
490
|
+
|
|
491
|
+
Raises
|
|
492
|
+
------
|
|
493
|
+
ValueError
|
|
494
|
+
If the input file format is not TCK.
|
|
495
|
+
|
|
496
|
+
FileExistsError
|
|
497
|
+
If the output file exists and force is False.
|
|
498
|
+
|
|
499
|
+
FileNotFoundError
|
|
500
|
+
If the reference image does not exist.
|
|
501
|
+
|
|
502
|
+
Examples
|
|
503
|
+
-----------
|
|
504
|
+
>>> tck2trk('input.tck', 'reference.nii.gz') # Saves as 'input.trk'
|
|
505
|
+
>>> tck2trk('input.tck', 'reference.nii.gz', 'output.trk') # Saves as 'output.trk'
|
|
506
|
+
>>> tck2trk('input.tck', 'reference.nii.gz', force=True) # Overwrites 'input.trk' if it exists
|
|
507
|
+
>>> tck2trk('input.tck', 'reference.nii.gz', out_tract='output.trk', force=True) # Overwrites 'output.trk' if it exists
|
|
508
|
+
|
|
509
|
+
"""
|
|
510
|
+
# Validate input file format
|
|
511
|
+
if nib.streamlines.detect_format(in_tract) is not nib.streamlines.TckFile:
|
|
512
|
+
raise ValueError(f"Invalid input file format: {in_tract} is not a TCK file.")
|
|
513
|
+
|
|
514
|
+
# Define output filename
|
|
515
|
+
if out_tract is None:
|
|
516
|
+
out_tract = in_tract.replace(".tck", ".trk")
|
|
517
|
+
|
|
518
|
+
# Handle overwrite scenario
|
|
519
|
+
if not os.path.exists(out_tract) or force:
|
|
520
|
+
# Load reference image
|
|
521
|
+
ref_nifti = nib.load(ref_img)
|
|
522
|
+
|
|
523
|
+
# Construct TRK header
|
|
524
|
+
header = {
|
|
525
|
+
Field.VOXEL_TO_RASMM: ref_nifti.affine.copy(),
|
|
526
|
+
Field.VOXEL_SIZES: ref_nifti.header.get_zooms()[:3],
|
|
527
|
+
Field.DIMENSIONS: ref_nifti.shape[:3],
|
|
528
|
+
Field.VOXEL_ORDER: "".join(aff2axcodes(ref_nifti.affine)),
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
# Load and save tractogram
|
|
532
|
+
tck = nib.streamlines.load(in_tract)
|
|
533
|
+
nib.streamlines.save(tck.tractogram, out_tract, header=header)
|
|
534
|
+
|
|
535
|
+
return out_tract
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
####################################################################################################
|
|
539
|
+
def trk2tck(in_tract: str, out_tract: str = None, force: bool = False) -> str:
|
|
540
|
+
"""
|
|
541
|
+
Convert a TRK file to a TCK file.
|
|
542
|
+
|
|
543
|
+
Parameters
|
|
544
|
+
----------
|
|
545
|
+
in_tract : str
|
|
546
|
+
Input TRK file.
|
|
547
|
+
|
|
548
|
+
out_tract : str, optional
|
|
549
|
+
Output TCK file. If None, the output file will have the same name as the input with the extension changed to TCK.
|
|
550
|
+
|
|
551
|
+
force : bool, optional
|
|
552
|
+
If True, overwrite the output file if it exists.
|
|
553
|
+
|
|
554
|
+
Returns
|
|
555
|
+
-------
|
|
556
|
+
out_tract : str
|
|
557
|
+
Output TCK file.
|
|
558
|
+
|
|
559
|
+
Examples
|
|
560
|
+
---------
|
|
561
|
+
>>> trk2tck('input.trk') # Saves as 'input.tck'
|
|
562
|
+
>>> trk2tck('input.trk', 'output.tck') # Saves as 'output.tck'
|
|
563
|
+
>>> trk2tck('input.trk', force=True) # Overwrites 'input.tck' if it exists
|
|
564
|
+
>>> trk2tck('input.trk', 'output.tck', force=True) # Overwrites 'output.tck' if it exists
|
|
565
|
+
|
|
566
|
+
"""
|
|
567
|
+
|
|
568
|
+
# Ensure the input is a TRK file
|
|
569
|
+
if nib.streamlines.detect_format(in_tract) is not nib.streamlines.TrkFile:
|
|
570
|
+
raise ValueError(f"Input file '{in_tract}' is not a valid TRK file.")
|
|
571
|
+
|
|
572
|
+
# Set output filename
|
|
573
|
+
if out_tract is None:
|
|
574
|
+
out_tract = in_tract.replace(".trk", ".tck")
|
|
575
|
+
|
|
576
|
+
# Check if output file exists
|
|
577
|
+
if os.path.isfile(out_tract) and not force:
|
|
578
|
+
raise FileExistsError(
|
|
579
|
+
f"File '{out_tract}' already exists. Use 'force=True' to overwrite."
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Load the TRK file
|
|
583
|
+
trk = nib.streamlines.load(in_tract)
|
|
584
|
+
|
|
585
|
+
# Save as a TCK file
|
|
586
|
+
nib.streamlines.save(trk.tractogram, out_tract)
|
|
587
|
+
|
|
588
|
+
return out_tract
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
####################################################################################################
|
|
592
|
+
def concatenate_tractograms(
|
|
593
|
+
in_tracts: list,
|
|
594
|
+
concat_tract: str = None,
|
|
595
|
+
show_progress: bool = False,
|
|
596
|
+
skip_missing: bool = False,
|
|
597
|
+
):
|
|
598
|
+
"""
|
|
599
|
+
Concatenate multiple tractograms with flexible error handling.
|
|
600
|
+
|
|
601
|
+
Parameters
|
|
602
|
+
----------
|
|
603
|
+
in_tracts : list of str
|
|
604
|
+
List of file paths to the tractograms to concatenate.
|
|
605
|
+
concat_tract : str, optional
|
|
606
|
+
File path for the output concatenated tractogram.
|
|
607
|
+
show_progress : bool, optional
|
|
608
|
+
Whether to show a progress bar during processing.
|
|
609
|
+
skip_missing : bool, optional
|
|
610
|
+
If True, skip missing files instead of raising an error.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
result : nibabel.streamlines.Tractogram or str
|
|
615
|
+
The concatenated tractogram or output file path.
|
|
616
|
+
Raises
|
|
617
|
+
------
|
|
618
|
+
ValueError
|
|
619
|
+
If the input tractograms are not a list or if less than two tractograms are provided.
|
|
620
|
+
FileNotFoundError
|
|
621
|
+
If any of the specified tractogram files are missing and `skip_missing` is False.
|
|
622
|
+
FileExistsError
|
|
623
|
+
If the output file already exists and `concat_tract` is specified without `skip_missing
|
|
624
|
+
|
|
625
|
+
Examples
|
|
626
|
+
--------
|
|
627
|
+
>>> in_tracts = ['tract1.trk', 'tract2.trk', 'tract3.trk']
|
|
628
|
+
>>> concat_tract = 'concatenated.trk'
|
|
629
|
+
>>> result = concatenate_tractograms(in_tracts, concat_tract, show_progress=True, skip_missing=True)
|
|
630
|
+
>>> print(f"Concatenated tractogram saved at: {result}")
|
|
631
|
+
"""
|
|
632
|
+
# Input validation
|
|
633
|
+
if not isinstance(in_tracts, list):
|
|
634
|
+
raise ValueError("trks must be a list of file paths.")
|
|
635
|
+
|
|
636
|
+
if len(in_tracts) < 2:
|
|
637
|
+
raise ValueError("At least two tractograms are required.")
|
|
638
|
+
|
|
639
|
+
if concat_tract is not None:
|
|
640
|
+
output_dir = os.path.dirname(concat_tract)
|
|
641
|
+
if output_dir and not os.path.exists(output_dir):
|
|
642
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
643
|
+
warnings.warn(f"Created output directory: {output_dir}")
|
|
644
|
+
|
|
645
|
+
# Filter existing files
|
|
646
|
+
existing_files = []
|
|
647
|
+
missing_files = []
|
|
648
|
+
|
|
649
|
+
for trk in in_tracts:
|
|
650
|
+
if os.path.exists(trk):
|
|
651
|
+
existing_files.append(trk)
|
|
652
|
+
else:
|
|
653
|
+
missing_files.append(trk)
|
|
654
|
+
|
|
655
|
+
# Handle missing files
|
|
656
|
+
if missing_files:
|
|
657
|
+
if skip_missing:
|
|
658
|
+
if len(existing_files) < 2:
|
|
659
|
+
raise ValueError(
|
|
660
|
+
f"After skipping missing files, less than 2 files remain. Missing: {missing_files}"
|
|
661
|
+
)
|
|
662
|
+
warnings.warn(f"Skipping missing files: {missing_files}")
|
|
663
|
+
else:
|
|
664
|
+
raise FileNotFoundError(f"Missing files: {missing_files}")
|
|
665
|
+
|
|
666
|
+
# Process files
|
|
667
|
+
trkall = None
|
|
668
|
+
files_to_process = existing_files
|
|
669
|
+
cont = 0
|
|
670
|
+
if show_progress:
|
|
671
|
+
progress = Progress(
|
|
672
|
+
TextColumn("[progress.description]{task.description}"),
|
|
673
|
+
BarColumn(),
|
|
674
|
+
SpinnerColumn(),
|
|
675
|
+
)
|
|
676
|
+
progress.start()
|
|
677
|
+
task = progress.add_task(
|
|
678
|
+
"Concatenating tractograms...", total=len(files_to_process)
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
for i, trk_file in enumerate(files_to_process):
|
|
683
|
+
if show_progress:
|
|
684
|
+
progress.update(
|
|
685
|
+
task,
|
|
686
|
+
description=f"Processing {os.path.basename(trk_file)} ({i+1}/{len(files_to_process)})",
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
trk = nib.streamlines.load(trk_file, lazy_load=False)
|
|
690
|
+
|
|
691
|
+
if cont == 0:
|
|
692
|
+
trkall = trk
|
|
693
|
+
else:
|
|
694
|
+
trkall.tractogram.streamlines.extend(trk.tractogram.streamlines)
|
|
695
|
+
cont += 1
|
|
696
|
+
|
|
697
|
+
else:
|
|
698
|
+
if show_progress:
|
|
699
|
+
progress.update(task, advance=1)
|
|
700
|
+
|
|
701
|
+
finally:
|
|
702
|
+
if show_progress:
|
|
703
|
+
progress.stop()
|
|
704
|
+
|
|
705
|
+
# Save or return
|
|
706
|
+
if concat_tract is not None:
|
|
707
|
+
nib.streamlines.save(trkall, concat_tract)
|
|
708
|
+
return concat_tract
|
|
709
|
+
|
|
710
|
+
return trkall
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
####################################################################################################
|
|
714
|
+
def resample_streamlines(
|
|
715
|
+
in_streamlines: nib.streamlines.array_sequence.ArraySequence, nb_points: int = 51
|
|
716
|
+
) -> nib.streamlines.array_sequence.ArraySequence:
|
|
717
|
+
"""
|
|
718
|
+
Resample streamlines to a specified number of points.
|
|
719
|
+
|
|
720
|
+
Parameters
|
|
721
|
+
----------
|
|
722
|
+
in_streamlines : nib.streamlines.array_sequence.ArraySequence
|
|
723
|
+
Input streamlines to be resampled.
|
|
724
|
+
|
|
725
|
+
nb_points : int, optional
|
|
726
|
+
Number of points to resample each streamline to. Default is 51.
|
|
727
|
+
|
|
728
|
+
Returns
|
|
729
|
+
-------
|
|
730
|
+
resampled_streamlines : nib.streamlines.array_sequence.ArraySequence
|
|
731
|
+
Resampled streamlines.
|
|
732
|
+
|
|
733
|
+
Raises
|
|
734
|
+
------
|
|
735
|
+
ValueError
|
|
736
|
+
If the input streamlines are not in the expected format.
|
|
737
|
+
|
|
738
|
+
ValueError
|
|
739
|
+
If the input streamlines are empty.
|
|
740
|
+
|
|
741
|
+
ValueError
|
|
742
|
+
If nb_points is not a positive integer.
|
|
743
|
+
|
|
744
|
+
Examples
|
|
745
|
+
--------
|
|
746
|
+
>>> in_streamlines = nib.streamlines.load('input.trk').streamlines
|
|
747
|
+
>>> nb_points = 100
|
|
748
|
+
>>> resampled_streamlines = resample_streamlines(in_streamlines, nb_points)
|
|
749
|
+
"""
|
|
750
|
+
# Check if input is an ArraySequence
|
|
751
|
+
if not isinstance(in_streamlines, nib.streamlines.array_sequence.ArraySequence):
|
|
752
|
+
raise ValueError(
|
|
753
|
+
"Input streamlines must be in the format of nibabel ArraySequence."
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# Check if the input streamlines are empty
|
|
757
|
+
if len(in_streamlines) == 0:
|
|
758
|
+
raise ValueError(
|
|
759
|
+
"Input streamlines are empty. Please provide valid streamlines."
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Check if nb_points is a positive integer
|
|
763
|
+
if not isinstance(nb_points, int) or nb_points <= 0:
|
|
764
|
+
raise ValueError("Number of points (nb_points) must be a positive integer.")
|
|
765
|
+
|
|
766
|
+
# Check if individual streamlines are valid numpy arrays
|
|
767
|
+
for i, streamline in enumerate(in_streamlines):
|
|
768
|
+
if not isinstance(streamline, np.ndarray):
|
|
769
|
+
raise ValueError(f"Streamline {i} is not a valid numpy array.")
|
|
770
|
+
|
|
771
|
+
if streamline.ndim != 2 or streamline.shape[1] != 3:
|
|
772
|
+
raise ValueError(
|
|
773
|
+
f"Streamline {i} must be a 2D array with shape (n_points, 3)."
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Resample each streamline to the specified number of points
|
|
777
|
+
resampled_streamlines = set_number_of_points(in_streamlines, nb_points)
|
|
778
|
+
|
|
779
|
+
return resampled_streamlines
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
####################################################################################################
|
|
783
|
+
def resample_tractogram(
|
|
784
|
+
in_tract: str,
|
|
785
|
+
out_tract: str = None,
|
|
786
|
+
nb_points: int = 51,
|
|
787
|
+
force: bool = False,
|
|
788
|
+
) -> str:
|
|
789
|
+
"""
|
|
790
|
+
Resample a tractogram to a specified number of points.
|
|
791
|
+
|
|
792
|
+
Parameters
|
|
793
|
+
----------
|
|
794
|
+
in_tract : str
|
|
795
|
+
Path to the input tractogram file (TRK or TCK format).
|
|
796
|
+
out_tract : str, optional
|
|
797
|
+
Path for the output resampled tractogram file. If None, it will replace the input file extension with .trk or .tck.
|
|
798
|
+
nb_points : int, optional
|
|
799
|
+
Number of points to resample each streamline to. Default is 51.
|
|
800
|
+
force : bool, optional
|
|
801
|
+
If True, overwrite the output file if it exists. Default is False.
|
|
802
|
+
|
|
803
|
+
Returns
|
|
804
|
+
-------
|
|
805
|
+
str
|
|
806
|
+
Path to the output resampled tractogram file.
|
|
807
|
+
|
|
808
|
+
Raises
|
|
809
|
+
------
|
|
810
|
+
ValueError
|
|
811
|
+
If the input file format is not supported.
|
|
812
|
+
FileExistsError
|
|
813
|
+
If the output file exists and force is False.
|
|
814
|
+
|
|
815
|
+
Examples
|
|
816
|
+
-----------
|
|
817
|
+
>>> resample_tractogram('input.trk', nb_points=100) # Saves as 'input_resampled.trk'
|
|
818
|
+
>>> resample_tractogram('input.tck', out_tract='output.tck', nb_points=100) # Saves as 'output.tck'
|
|
819
|
+
>>> resample_tractogram('input.trk', force=True) # Overwrites 'input_resampled.trk' if it exists
|
|
820
|
+
|
|
821
|
+
"""
|
|
822
|
+
|
|
823
|
+
# Validate input file format
|
|
824
|
+
if nib.streamlines.detect_format(in_tract) not in [
|
|
825
|
+
nib.streamlines.TrkFile,
|
|
826
|
+
nib.streamlines.TckFile,
|
|
827
|
+
]:
|
|
828
|
+
raise ValueError(f"Invalid input file format: {in_tract}. Must be TRK or TCK.")
|
|
829
|
+
|
|
830
|
+
# Define output filename
|
|
831
|
+
if out_tract is not None and os.path.exists(out_tract) and not force:
|
|
832
|
+
raise FileExistsError(
|
|
833
|
+
f"Output file '{out_tract}' already exists. Use 'force=True' to overwrite."
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
# Load the tractogram
|
|
837
|
+
trk = nib.streamlines.load(in_tract)
|
|
838
|
+
|
|
839
|
+
# Resample each streamline to the specified number of points
|
|
840
|
+
resampled_streamlines = resample_streamlines(trk.streamlines, nb_points)
|
|
841
|
+
|
|
842
|
+
# Create a new tractogram with the resampled streamlines
|
|
843
|
+
resampled_tractogram = nib.streamlines.Tractogram(
|
|
844
|
+
resampled_streamlines, affine_to_rasmm=trk.tractogram.affine_to_rasmm
|
|
845
|
+
)
|
|
846
|
+
# Create a header (copy from original)
|
|
847
|
+
header = trk.header.copy()
|
|
848
|
+
|
|
849
|
+
# Update count in header
|
|
850
|
+
header["nb_streamlines"] = len(resampled_streamlines)
|
|
851
|
+
|
|
852
|
+
if out_tract is None:
|
|
853
|
+
return resampled_streamlines
|
|
854
|
+
else:
|
|
855
|
+
# Check if the output directory exists, if not raise an error
|
|
856
|
+
out_dir = os.path.dirname(out_tract)
|
|
857
|
+
if out_dir and not os.path.exists(out_dir):
|
|
858
|
+
raise FileNotFoundError(
|
|
859
|
+
f"Output directory '{out_dir}' does not exist. Please create it before saving the resampled tractogram."
|
|
860
|
+
)
|
|
861
|
+
# If the output directory exists, create it if it doesn't exist
|
|
862
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
863
|
+
|
|
864
|
+
# Save the resampled tractogram
|
|
865
|
+
nib.streamlines.save(resampled_tractogram, out_tract, header=header)
|
|
866
|
+
|
|
867
|
+
return out_tract
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
####################################################################################################
|
|
871
|
+
def compute_tractogram_centroids(
|
|
872
|
+
in_tract: str,
|
|
873
|
+
centroid_tract: str,
|
|
874
|
+
clustered_tract: str = None,
|
|
875
|
+
nb_points=51,
|
|
876
|
+
method="qb",
|
|
877
|
+
thresholds=[10],
|
|
878
|
+
save_scalars_for_trackvis=True,
|
|
879
|
+
):
|
|
880
|
+
"""
|
|
881
|
+
Extract bundle centroids from tractogram and save them as .trk files.
|
|
882
|
+
|
|
883
|
+
Parameters
|
|
884
|
+
----------
|
|
885
|
+
in_tract : str
|
|
886
|
+
Path to input tractogram file (.trk)
|
|
887
|
+
|
|
888
|
+
centroid_tract : str
|
|
889
|
+
Path to output file for centroids (.trk)
|
|
890
|
+
|
|
891
|
+
clustered_tract : str
|
|
892
|
+
Path to output file for clustered streamlines (.trk)
|
|
893
|
+
|
|
894
|
+
nb_points : int, optional
|
|
895
|
+
Number of points to resample the streamlines to. Default is 51.
|
|
896
|
+
|
|
897
|
+
method : str, optional
|
|
898
|
+
Clustering method to use. Can be 'qbx' or 'qb'. Default is 'qb'.
|
|
899
|
+
|
|
900
|
+
thresholds : list of int, optional
|
|
901
|
+
List of thresholds to use for clustering (only for qbx). Default is [10].
|
|
902
|
+
If using 'qb', only the first threshold will be used.
|
|
903
|
+
|
|
904
|
+
save_scalars_for_trackvis : bool, optional
|
|
905
|
+
If True, saves scalar data in TrackVis-compatible format. Default is True.
|
|
906
|
+
|
|
907
|
+
Returns
|
|
908
|
+
-------
|
|
909
|
+
dict
|
|
910
|
+
Dictionary containing clustering information (number of clusters, etc.)
|
|
911
|
+
|
|
912
|
+
Raises
|
|
913
|
+
------
|
|
914
|
+
FileNotFoundError
|
|
915
|
+
If the input tracking file does not exist or output directories do not exist.
|
|
916
|
+
|
|
917
|
+
ValueError
|
|
918
|
+
If the input tracking file is not in the expected format or if nb_points is not a positive integer.
|
|
919
|
+
|
|
920
|
+
ValueError
|
|
921
|
+
If the clustering method is not recognized or if thresholds are not provided correctly.
|
|
922
|
+
|
|
923
|
+
ValueError
|
|
924
|
+
If the input streamlines are empty or not in the expected format.
|
|
925
|
+
|
|
926
|
+
Examples
|
|
927
|
+
-----------
|
|
928
|
+
>>> compute_tractogram_centroids('input.trk', 'centroids.trk', 'clustered.trk', nb_points=100, method='qb', thresholds=[10])
|
|
929
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qbx', thresholds=[5,
|
|
930
|
+
10, 15], save_scalars_for_trackvis=True)
|
|
931
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qb', thresholds=[10], save
|
|
932
|
+
_scalars_for_trackvis=False)
|
|
933
|
+
>>> compute_tractogram_centroids('input.trk', 'centroids.trk', 'clustered.trk', nb_points=100, method='qbx', thresholds=[5, 10, 15], save_scalars_for_trackvis=True)
|
|
934
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qb', thresholds=[10], save_scalars_for_trackvis=True)
|
|
935
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qbx', thresholds=[5, 10, 15], save_scalars_for_trackvis=False)
|
|
936
|
+
>>> compute_tractogram_centroids('input.trk', 'centroids.trk', 'clustered.trk', nb_points=100, method='qb', thresholds=[10], save_scalars_for_trackvis=False)
|
|
937
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qbx', thresholds=[5, 10, 15], save_scalars_for_trackvis=True)
|
|
938
|
+
>>> compute_tractogram_centroids('input.tck', 'centroids.trk', 'clustered.trk', nb_points=100, method='qb', thresholds=[10], save_scalars_for_trackvis=True)
|
|
939
|
+
|
|
940
|
+
"""
|
|
941
|
+
|
|
942
|
+
# Check if the input tracking file exists
|
|
943
|
+
if not os.path.isfile(in_tract):
|
|
944
|
+
raise FileNotFoundError(f"Input tracking file {in_tract} does not exist.")
|
|
945
|
+
|
|
946
|
+
# Check if output directories exist
|
|
947
|
+
# If the output directories do not exist, create them
|
|
948
|
+
centroid_dir = os.path.dirname(centroid_tract)
|
|
949
|
+
if not os.path.exists(centroid_dir):
|
|
950
|
+
# Raise an error if the directory does not exist
|
|
951
|
+
raise FileNotFoundError(f"Output directory {centroid_dir} does not exist.")
|
|
952
|
+
|
|
953
|
+
if clustered_tract is not None:
|
|
954
|
+
clustered_dir = os.path.dirname(clustered_tract)
|
|
955
|
+
if not os.path.exists(clustered_dir):
|
|
956
|
+
# Raise an error if the directory does not exist
|
|
957
|
+
raise FileNotFoundError(f"Output directory {clustered_dir} does not exist.")
|
|
958
|
+
|
|
959
|
+
# Load the tractogram
|
|
960
|
+
tractogram = nib.streamlines.load(in_tract)
|
|
961
|
+
original_streamlines = tractogram.streamlines
|
|
962
|
+
|
|
963
|
+
# === PREPROCESS: RESAMPLE ===
|
|
964
|
+
if nb_points is not None:
|
|
965
|
+
# Check if nb_points is a positive integer
|
|
966
|
+
if not isinstance(nb_points, int) or nb_points <= 0:
|
|
967
|
+
raise ValueError("Number of points (nb_points) must be a positive integer.")
|
|
968
|
+
# Resample the streamlines to the specified number of points
|
|
969
|
+
streamlines = resample_streamlines(original_streamlines, nb_points)
|
|
970
|
+
else:
|
|
971
|
+
# If nb_points is None, use the original streamlines
|
|
972
|
+
streamlines = original_streamlines
|
|
973
|
+
|
|
974
|
+
# === CLUSTERING ===
|
|
975
|
+
if method == "qbx":
|
|
976
|
+
if not isinstance(thresholds, list) or len(thresholds) == 0:
|
|
977
|
+
raise ValueError("Thresholds must be a non-empty list for QuickBundlesX")
|
|
978
|
+
qbx = QuickBundlesX(thresholds)
|
|
979
|
+
clusters = qbx.cluster(streamlines)
|
|
980
|
+
|
|
981
|
+
elif method == "qb":
|
|
982
|
+
# Use the first threshold if provided, otherwise default to 10
|
|
983
|
+
threshold = thresholds[0] if thresholds else 10
|
|
984
|
+
qb = QuickBundles(threshold=threshold)
|
|
985
|
+
clusters = qb.cluster(streamlines)
|
|
986
|
+
|
|
987
|
+
else:
|
|
988
|
+
raise ValueError(f"Unknown clustering method: {method}. Use 'qb' or 'qbx'.")
|
|
989
|
+
|
|
990
|
+
# Collect centroids and clustered streamlines with metadata
|
|
991
|
+
centroids_list = []
|
|
992
|
+
clustered_streamlines_list = []
|
|
993
|
+
|
|
994
|
+
# Metadata for centroids
|
|
995
|
+
centroid_ids = []
|
|
996
|
+
cluster_sizes = []
|
|
997
|
+
|
|
998
|
+
# Metadata for clustered streamlines
|
|
999
|
+
cluster_labels = []
|
|
1000
|
+
original_indices = []
|
|
1001
|
+
distances_to_centroid = []
|
|
1002
|
+
|
|
1003
|
+
# Process each cluster
|
|
1004
|
+
for i, cluster in enumerate(clusters):
|
|
1005
|
+
# Get the centroid of the cluster
|
|
1006
|
+
cluster_centroid = cluster.centroid
|
|
1007
|
+
centroids_list.append(cluster_centroid)
|
|
1008
|
+
|
|
1009
|
+
# Metadata for centroids
|
|
1010
|
+
centroid_ids.append(i)
|
|
1011
|
+
cluster_sizes.append(len(cluster.indices))
|
|
1012
|
+
|
|
1013
|
+
# Get the streamlines for this cluster
|
|
1014
|
+
cluster_streamlines = streamlines[cluster.indices]
|
|
1015
|
+
clustered_streamlines_list.extend(cluster_streamlines)
|
|
1016
|
+
|
|
1017
|
+
# Metadata for clustered streamlines
|
|
1018
|
+
cluster_labels.extend([i] * len(cluster.indices))
|
|
1019
|
+
original_indices.extend(cluster.indices)
|
|
1020
|
+
|
|
1021
|
+
# Calculate distances to centroid for each streamline in cluster
|
|
1022
|
+
for j, idx in enumerate(cluster.indices):
|
|
1023
|
+
# Simple distance metric: you can customize this
|
|
1024
|
+
# For now, we'll use the cluster's internal distance if available
|
|
1025
|
+
try:
|
|
1026
|
+
if hasattr(cluster, "distances") and cluster.distances is not None:
|
|
1027
|
+
distance = (
|
|
1028
|
+
cluster.distances[j] if j < len(cluster.distances) else 0.0
|
|
1029
|
+
)
|
|
1030
|
+
else:
|
|
1031
|
+
# Fallback: set distance to 0 (could implement custom distance calculation here)
|
|
1032
|
+
distance = 0.0
|
|
1033
|
+
except:
|
|
1034
|
+
distance = 0.0
|
|
1035
|
+
distances_to_centroid.append(distance)
|
|
1036
|
+
|
|
1037
|
+
# Convert to ArraySequence for nibabel
|
|
1038
|
+
centroids_array = ArraySequence(centroids_list)
|
|
1039
|
+
clustered_streamlines_array = ArraySequence(clustered_streamlines_list)
|
|
1040
|
+
|
|
1041
|
+
# Create tractograms with metadata for TrackVis compatibility
|
|
1042
|
+
# For centroids tractogram
|
|
1043
|
+
centroids_tractogram = nib.streamlines.Tractogram(
|
|
1044
|
+
streamlines=centroids_array,
|
|
1045
|
+
affine_to_rasmm=tractogram.tractogram.affine_to_rasmm,
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
# For clustered streamlines tractogram
|
|
1049
|
+
clustered_tractogram = nib.streamlines.Tractogram(
|
|
1050
|
+
streamlines=clustered_streamlines_array,
|
|
1051
|
+
affine_to_rasmm=tractogram.tractogram.affine_to_rasmm,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
if save_scalars_for_trackvis:
|
|
1055
|
+
# TrackVis-compatible scalar format
|
|
1056
|
+
# Store cluster IDs as scalar data per streamline
|
|
1057
|
+
# Each streamline gets its cluster ID as a scalar value
|
|
1058
|
+
|
|
1059
|
+
# For centroids: store centroid ID and cluster size
|
|
1060
|
+
centroid_scalar_data = []
|
|
1061
|
+
for i in range(len(centroids_list)):
|
|
1062
|
+
# Create scalar data: [centroid_id, cluster_size]
|
|
1063
|
+
scalars = np.array([[centroid_ids[i], cluster_sizes[i]]], dtype=np.float32)
|
|
1064
|
+
centroid_scalar_data.append(scalars)
|
|
1065
|
+
|
|
1066
|
+
# For clustered streamlines: store cluster_id as scalar
|
|
1067
|
+
clustered_scalar_data = []
|
|
1068
|
+
for i in range(len(clustered_streamlines_list)):
|
|
1069
|
+
# Create scalar data: [cluster_id, distance_to_centroid]
|
|
1070
|
+
scalars = np.array(
|
|
1071
|
+
[[cluster_labels[i], distances_to_centroid[i]]], dtype=np.float32
|
|
1072
|
+
)
|
|
1073
|
+
clustered_scalar_data.append(scalars)
|
|
1074
|
+
|
|
1075
|
+
# Note: TrackVis expects scalars in a specific format
|
|
1076
|
+
# We'll store this in data_per_streamline for nibabel compatibility
|
|
1077
|
+
# The scalar data will be written to the TRK file in the proper format
|
|
1078
|
+
|
|
1079
|
+
# Always store metadata in nibabel format for programmatic access
|
|
1080
|
+
centroids_tractogram.data_per_streamline = {
|
|
1081
|
+
"centroid_id": np.array(centroid_ids, dtype=np.int32),
|
|
1082
|
+
"cluster_size": np.array(cluster_sizes, dtype=np.int32),
|
|
1083
|
+
}
|
|
1084
|
+
clustered_tractogram.data_per_streamline = {
|
|
1085
|
+
"cluster_id": np.array(cluster_labels, dtype=np.int32),
|
|
1086
|
+
"original_index": np.array(original_indices, dtype=np.int32),
|
|
1087
|
+
"distance_to_centroid": np.array(distances_to_centroid, dtype=np.float32),
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
# Create TrkFile objects
|
|
1091
|
+
centroids_trk = nib.streamlines.TrkFile(
|
|
1092
|
+
tractogram=centroids_tractogram, header=tractogram.header.copy()
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
clustered_trk = nib.streamlines.TrkFile(
|
|
1096
|
+
tractogram=clustered_tractogram, header=tractogram.header.copy()
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
# Update headers with new streamline counts
|
|
1100
|
+
centroids_trk.header["nb_streamlines"] = len(centroids_list)
|
|
1101
|
+
|
|
1102
|
+
clustered_trk.header["nb_streamlines"] = len(clustered_streamlines_list)
|
|
1103
|
+
|
|
1104
|
+
# Save the files
|
|
1105
|
+
nib.streamlines.save(centroids_trk, centroid_tract)
|
|
1106
|
+
|
|
1107
|
+
if clustered_tract is not None:
|
|
1108
|
+
nib.streamlines.save(clustered_trk, clustered_tract)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
########################################################################################################
|
|
1112
|
+
def create_trackvis_colored_trk(
|
|
1113
|
+
clustered_trk_path: str, output_path: str, color_by="cluster_id"
|
|
1114
|
+
):
|
|
1115
|
+
"""
|
|
1116
|
+
Create a TrackVis-compatible TRK file with scalar coloring.
|
|
1117
|
+
|
|
1118
|
+
Parameters
|
|
1119
|
+
----------
|
|
1120
|
+
clustered_trk_path : str
|
|
1121
|
+
Path to the clustered streamlines TRK file
|
|
1122
|
+
|
|
1123
|
+
output_path : str
|
|
1124
|
+
Path for the output TRK file optimized for TrackVis coloring
|
|
1125
|
+
|
|
1126
|
+
color_by : str, optional
|
|
1127
|
+
Which metadata field to use for coloring. Options: 'cluster_id',
|
|
1128
|
+
'distance_to_centroid', 'original_index'. Default is 'cluster_id'.
|
|
1129
|
+
|
|
1130
|
+
Returns
|
|
1131
|
+
-------
|
|
1132
|
+
str
|
|
1133
|
+
Path to the created file
|
|
1134
|
+
|
|
1135
|
+
Raises
|
|
1136
|
+
------
|
|
1137
|
+
ValueError
|
|
1138
|
+
If the input file does not contain the specified color_by metadata,
|
|
1139
|
+
or if the input file does not contain any metadata.
|
|
1140
|
+
FileNotFoundError
|
|
1141
|
+
If the input clustered_trk_path does not exist.
|
|
1142
|
+
|
|
1143
|
+
Examples
|
|
1144
|
+
-----------
|
|
1145
|
+
>>> create_trackvis_colored_trk('clustered.trk', 'colored_output.trk', color_by='cluster_id')
|
|
1146
|
+
"""
|
|
1147
|
+
|
|
1148
|
+
# Check if the input file exists
|
|
1149
|
+
if not os.path.isfile(clustered_trk_path):
|
|
1150
|
+
raise FileNotFoundError(f"Input file {clustered_trk_path} does not exist.")
|
|
1151
|
+
|
|
1152
|
+
# Check if the output directory exists, if not raise an error
|
|
1153
|
+
output_dir = os.path.dirname(output_path)
|
|
1154
|
+
if output_dir and not os.path.exists(output_dir):
|
|
1155
|
+
raise FileNotFoundError(
|
|
1156
|
+
f"Output directory {output_dir} does not exist. Please create it before saving the colored TRK file."
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
# Load the tractogram
|
|
1160
|
+
tractogram = nib.streamlines.load(clustered_trk_path)
|
|
1161
|
+
|
|
1162
|
+
if not hasattr(tractogram.tractogram, "data_per_streamline"):
|
|
1163
|
+
raise ValueError("No metadata found in the input file.")
|
|
1164
|
+
|
|
1165
|
+
metadata = tractogram.tractogram.data_per_streamline
|
|
1166
|
+
|
|
1167
|
+
if color_by not in metadata:
|
|
1168
|
+
available_keys = list(metadata.keys())
|
|
1169
|
+
raise ValueError(f"'{color_by}' not found. Available keys: {available_keys}")
|
|
1170
|
+
|
|
1171
|
+
# Get the scalar values for coloring
|
|
1172
|
+
scalar_values = metadata[color_by]
|
|
1173
|
+
|
|
1174
|
+
# Create a new tractogram with scalar data in TrackVis format
|
|
1175
|
+
streamlines_with_scalars = []
|
|
1176
|
+
|
|
1177
|
+
for i, streamline in enumerate(tractogram.streamlines):
|
|
1178
|
+
# For TrackVis, we need to create a streamline with scalar data
|
|
1179
|
+
# The scalar value is repeated for each point in the streamline
|
|
1180
|
+
scalar_value = float(scalar_values[i])
|
|
1181
|
+
|
|
1182
|
+
# Create scalar array for this streamline (one scalar per point)
|
|
1183
|
+
n_points = len(streamline)
|
|
1184
|
+
scalars = np.full((n_points, 1), scalar_value, dtype=np.float32)
|
|
1185
|
+
|
|
1186
|
+
# Store as tuple (streamline_points, scalars)
|
|
1187
|
+
streamlines_with_scalars.append((streamline, scalars))
|
|
1188
|
+
|
|
1189
|
+
# Create header for the new file
|
|
1190
|
+
new_header = tractogram.header.copy()
|
|
1191
|
+
new_header["n_scalars"] = 1 # One scalar per point
|
|
1192
|
+
new_header["scalar_name"] = [color_by.encode("utf-8")]
|
|
1193
|
+
new_header["n_properties"] = 0
|
|
1194
|
+
|
|
1195
|
+
# Use DIPY's save_trk function which handles TrackVis format properly
|
|
1196
|
+
from dipy.io.streamline import save_trk
|
|
1197
|
+
from dipy.io.stateful_tractogram import StatefulTractogram
|
|
1198
|
+
from dipy.io.utils import create_tractogram_header
|
|
1199
|
+
|
|
1200
|
+
# Extract just the streamlines and scalars
|
|
1201
|
+
streamlines_only = [s[0] for s in streamlines_with_scalars]
|
|
1202
|
+
scalars_only = [s[1] for s in streamlines_with_scalars]
|
|
1203
|
+
|
|
1204
|
+
# Create StatefulTractogram with scalars
|
|
1205
|
+
sft = StatefulTractogram(
|
|
1206
|
+
streamlines_only,
|
|
1207
|
+
tractogram.header, # Use original header for spatial info
|
|
1208
|
+
Space.RASMM,
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
# Add scalar data
|
|
1212
|
+
sft.data_per_point = {color_by: scalars_only}
|
|
1213
|
+
|
|
1214
|
+
# Save with DIPY
|
|
1215
|
+
save_trk(sft, output_path)
|
|
1216
|
+
|
|
1217
|
+
print(f"TrackVis-compatible file saved: {output_path}")
|
|
1218
|
+
print(f"Colored by: {color_by}")
|
|
1219
|
+
print(f"Scalar range: {np.min(scalar_values):.2f} - {np.max(scalar_values):.2f}")
|
|
1220
|
+
print("\nTo view in TrackVis:")
|
|
1221
|
+
print("1. Open the .trk file in TrackVis")
|
|
1222
|
+
print("2. In the Property panel, find 'Color Code'")
|
|
1223
|
+
print("3. Change from 'Directional' to 'Scalar'")
|
|
1224
|
+
print(f"4. The streamlines will be colored by {color_by}")
|
|
1225
|
+
|
|
1226
|
+
return output_path
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
#####################################################################################################
|
|
1230
|
+
def extract_cluster_by_id(
|
|
1231
|
+
clustered_trk_path: str, cluster_ids: Union[List[int], int], output_path=None
|
|
1232
|
+
):
|
|
1233
|
+
"""
|
|
1234
|
+
Extract all streamlines belonging to specific clusters.
|
|
1235
|
+
|
|
1236
|
+
Parameters
|
|
1237
|
+
----------
|
|
1238
|
+
clustered_trk_path : str
|
|
1239
|
+
Path to the clustered streamlines TRK file
|
|
1240
|
+
|
|
1241
|
+
cluster_ids : int or list of int
|
|
1242
|
+
Cluster ID or list of cluster IDs to extract.
|
|
1243
|
+
If a single integer is provided, extracts that cluster.
|
|
1244
|
+
If a list is provided, extracts all specified clusters.
|
|
1245
|
+
|
|
1246
|
+
output_path : str, optional
|
|
1247
|
+
Path to save the extracted cluster. If None, returns the data.
|
|
1248
|
+
|
|
1249
|
+
Returns
|
|
1250
|
+
-------
|
|
1251
|
+
dict or None
|
|
1252
|
+
If output_path is None, returns dictionary with streamlines and metadata
|
|
1253
|
+
"""
|
|
1254
|
+
|
|
1255
|
+
# Check if the input file exists
|
|
1256
|
+
if not os.path.isfile(clustered_trk_path):
|
|
1257
|
+
raise FileNotFoundError(f"Input file {clustered_trk_path} does not exist.")
|
|
1258
|
+
|
|
1259
|
+
# Check if output directory exists, if not raise an error
|
|
1260
|
+
if output_path is not None:
|
|
1261
|
+
output_dir = os.path.dirname(output_path)
|
|
1262
|
+
if output_dir and not os.path.exists(output_dir):
|
|
1263
|
+
raise FileNotFoundError(
|
|
1264
|
+
f"Output directory {output_dir} does not exist. Please create it before saving the extracted cluster."
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# Load the tractogram
|
|
1268
|
+
tractogram = nib.streamlines.load(clustered_trk_path)
|
|
1269
|
+
|
|
1270
|
+
if (
|
|
1271
|
+
not hasattr(tractogram.tractogram, "data_per_streamline")
|
|
1272
|
+
or "cluster_id" not in tractogram.tractogram.data_per_streamline
|
|
1273
|
+
):
|
|
1274
|
+
raise ValueError("No cluster_id metadata found in the file.")
|
|
1275
|
+
|
|
1276
|
+
streamline_cluster_ids = tractogram.tractogram.data_per_streamline["cluster_id"]
|
|
1277
|
+
|
|
1278
|
+
# Find indices of streamlines belonging to the specified cluster
|
|
1279
|
+
if isinstance(cluster_ids, (int, np.integer)):
|
|
1280
|
+
# Handle single integer
|
|
1281
|
+
cluster_indices = [
|
|
1282
|
+
i for i, cid in enumerate(streamline_cluster_ids) if cid == cluster_ids
|
|
1283
|
+
]
|
|
1284
|
+
else:
|
|
1285
|
+
# Handle list of integers
|
|
1286
|
+
cluster_indices = [
|
|
1287
|
+
i for i, cid in enumerate(streamline_cluster_ids) if cid in cluster_ids
|
|
1288
|
+
]
|
|
1289
|
+
|
|
1290
|
+
if len(cluster_indices) == 0:
|
|
1291
|
+
raise ValueError(f"No streamlines found for cluster(s): {cluster_ids}")
|
|
1292
|
+
|
|
1293
|
+
# Extract streamlines
|
|
1294
|
+
cluster_streamlines = [tractogram.streamlines[i] for i in cluster_indices]
|
|
1295
|
+
|
|
1296
|
+
# Extract corresponding metadata
|
|
1297
|
+
cluster_metadata = {}
|
|
1298
|
+
for key, values in tractogram.tractogram.data_per_streamline.items():
|
|
1299
|
+
cluster_values = [values[i] for i in cluster_indices]
|
|
1300
|
+
# Convert to numpy array with appropriate dtype
|
|
1301
|
+
if key in ["centroid_id", "cluster_id", "original_index", "cluster_size"]:
|
|
1302
|
+
cluster_metadata[key] = np.array(cluster_values, dtype=np.int32)
|
|
1303
|
+
else: # for distance_to_centroid and other float values
|
|
1304
|
+
cluster_metadata[key] = np.array(cluster_values, dtype=np.float32)
|
|
1305
|
+
|
|
1306
|
+
print(
|
|
1307
|
+
f"Extracted {len(cluster_streamlines)} streamlines from cluster(s): {cluster_ids}"
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
if output_path:
|
|
1311
|
+
# Create new tractogram
|
|
1312
|
+
new_tractogram = nib.streamlines.Tractogram(
|
|
1313
|
+
streamlines=ArraySequence(cluster_streamlines),
|
|
1314
|
+
affine_to_rasmm=tractogram.tractogram.affine_to_rasmm,
|
|
1315
|
+
)
|
|
1316
|
+
new_tractogram.data_per_streamline = cluster_metadata
|
|
1317
|
+
|
|
1318
|
+
# Create and save TrkFile
|
|
1319
|
+
new_trk = nib.streamlines.TrkFile(
|
|
1320
|
+
tractogram=new_tractogram, header=tractogram.header.copy()
|
|
1321
|
+
)
|
|
1322
|
+
new_trk.header["nb_streamlines"] = len(cluster_streamlines)
|
|
1323
|
+
|
|
1324
|
+
nib.streamlines.save(new_trk, output_path)
|
|
1325
|
+
|
|
1326
|
+
return None
|
|
1327
|
+
else:
|
|
1328
|
+
return {
|
|
1329
|
+
"streamlines": cluster_streamlines,
|
|
1330
|
+
"metadata": cluster_metadata,
|
|
1331
|
+
"cluster_ids": (
|
|
1332
|
+
cluster_ids if isinstance(cluster_ids, list) else [cluster_ids]
|
|
1333
|
+
),
|
|
1334
|
+
"n_streamlines": len(cluster_streamlines),
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
#####################################################################################################
|
|
1339
|
+
class TRKExplorer:
|
|
1340
|
+
"""
|
|
1341
|
+
A class to explore and summarize TRK (TrackVis) format tractogram files using nibabel.
|
|
1342
|
+
"""
|
|
1343
|
+
|
|
1344
|
+
def __init__(self, filepath: str):
|
|
1345
|
+
"""
|
|
1346
|
+
Initialize the TRK explorer with a file path.
|
|
1347
|
+
|
|
1348
|
+
Parameters
|
|
1349
|
+
----------
|
|
1350
|
+
filepath : str
|
|
1351
|
+
Path to the TRK file to explore.
|
|
1352
|
+
|
|
1353
|
+
Raises
|
|
1354
|
+
------
|
|
1355
|
+
FileNotFoundError
|
|
1356
|
+
If the specified TRK file does not exist.
|
|
1357
|
+
|
|
1358
|
+
Examples
|
|
1359
|
+
-----------
|
|
1360
|
+
>>> explorer = TRKExplorer('path/to/your/file.trk')
|
|
1361
|
+
>>> summary = explorer.explore(max_streamline_samples=10)
|
|
1362
|
+
>>> print(summary)
|
|
1363
|
+
>>> # To get detailed information about streamlines and properties
|
|
1364
|
+
>>> explorer._analyze_streamlines(max_sample=5)
|
|
1365
|
+
>>> explorer._analyze_data_properties()
|
|
1366
|
+
>>> print(explorer.streamlines_info)
|
|
1367
|
+
>>> print(explorer.data_properties)
|
|
1368
|
+
"""
|
|
1369
|
+
self.filepath = Path(filepath)
|
|
1370
|
+
self.trk_file = None
|
|
1371
|
+
self.header = {}
|
|
1372
|
+
self.streamlines_info = {}
|
|
1373
|
+
self.data_properties = {}
|
|
1374
|
+
|
|
1375
|
+
if not self.filepath.exists():
|
|
1376
|
+
raise FileNotFoundError(f"TRK file not found: {filepath}")
|
|
1377
|
+
|
|
1378
|
+
self._load_trk_file()
|
|
1379
|
+
|
|
1380
|
+
def _load_trk_file(self):
|
|
1381
|
+
"""Load the TRK file using nibabel."""
|
|
1382
|
+
self.trk_file = nib.streamlines.load(str(self.filepath))
|
|
1383
|
+
self.header = self.trk_file.header
|
|
1384
|
+
|
|
1385
|
+
def _analyze_streamlines(self, max_sample: int = 5):
|
|
1386
|
+
"""
|
|
1387
|
+
Analyze streamlines using nibabel.
|
|
1388
|
+
|
|
1389
|
+
Parameters
|
|
1390
|
+
----------
|
|
1391
|
+
max_sample : int, optional
|
|
1392
|
+
Maximum number of streamlines to sample for detailed info.
|
|
1393
|
+
|
|
1394
|
+
Raises
|
|
1395
|
+
------
|
|
1396
|
+
ValueError
|
|
1397
|
+
If the TRK file does not contain any streamlines or if the number of streamlines
|
|
1398
|
+
exceeds the specified maximum sample size.
|
|
1399
|
+
|
|
1400
|
+
Notes
|
|
1401
|
+
-----
|
|
1402
|
+
This method collects detailed information about the streamlines in the TRK file,
|
|
1403
|
+
including their lengths, sizes, and data types. It also computes statistics such as
|
|
1404
|
+
the total number of streamlines, minimum and maximum lengths, and average length.
|
|
1405
|
+
The results are stored in the `streamlines_info` attribute of the class.
|
|
1406
|
+
|
|
1407
|
+
"""
|
|
1408
|
+
streamlines = self.trk_file.streamlines
|
|
1409
|
+
n_streamlines = len(streamlines)
|
|
1410
|
+
|
|
1411
|
+
streamlines_info = {
|
|
1412
|
+
"total_count": n_streamlines,
|
|
1413
|
+
"samples": [],
|
|
1414
|
+
"statistics": {
|
|
1415
|
+
"lengths": [],
|
|
1416
|
+
"total_points": 0,
|
|
1417
|
+
"min_length": float("inf"),
|
|
1418
|
+
"max_length": 0,
|
|
1419
|
+
"avg_length": 0,
|
|
1420
|
+
},
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
if n_streamlines == 0:
|
|
1424
|
+
self.streamlines_info = streamlines_info
|
|
1425
|
+
return
|
|
1426
|
+
|
|
1427
|
+
lengths = []
|
|
1428
|
+
total_points = 0
|
|
1429
|
+
sample_count = 0
|
|
1430
|
+
|
|
1431
|
+
# Analyze streamlines
|
|
1432
|
+
for i, streamline in enumerate(streamlines):
|
|
1433
|
+
n_points = len(streamline)
|
|
1434
|
+
lengths.append(n_points)
|
|
1435
|
+
total_points += n_points
|
|
1436
|
+
|
|
1437
|
+
# Store sample information
|
|
1438
|
+
if sample_count < max_sample:
|
|
1439
|
+
streamline_size_kb = (n_points * 3 * 4) / 1024 # xyz coords in float32
|
|
1440
|
+
streamlines_info["samples"].append(
|
|
1441
|
+
{
|
|
1442
|
+
"index": i,
|
|
1443
|
+
"n_points": n_points,
|
|
1444
|
+
"size_kb": streamline_size_kb,
|
|
1445
|
+
"data_type": "float32",
|
|
1446
|
+
}
|
|
1447
|
+
)
|
|
1448
|
+
sample_count += 1
|
|
1449
|
+
|
|
1450
|
+
# Calculate statistics
|
|
1451
|
+
streamlines_info["statistics"] = {
|
|
1452
|
+
"lengths": lengths,
|
|
1453
|
+
"total_points": total_points,
|
|
1454
|
+
"min_length": min(lengths) if lengths else 0,
|
|
1455
|
+
"max_length": max(lengths) if lengths else 0,
|
|
1456
|
+
"avg_length": sum(lengths) / len(lengths) if lengths else 0,
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
self.streamlines_info = streamlines_info
|
|
1460
|
+
|
|
1461
|
+
def _analyze_data_properties(self):
|
|
1462
|
+
"""Analyze data properties using nibabel."""
|
|
1463
|
+
properties = {}
|
|
1464
|
+
|
|
1465
|
+
# Get scalar names from header
|
|
1466
|
+
scalar_names = []
|
|
1467
|
+
if "scalar_name" in self.header:
|
|
1468
|
+
scalar_names = [
|
|
1469
|
+
name.decode("utf-8") if isinstance(name, bytes) else name
|
|
1470
|
+
for name in self.header["scalar_name"]
|
|
1471
|
+
if name and name.strip()
|
|
1472
|
+
]
|
|
1473
|
+
|
|
1474
|
+
# Get property names from header
|
|
1475
|
+
property_names = []
|
|
1476
|
+
if "property_name" in self.header:
|
|
1477
|
+
property_names = [
|
|
1478
|
+
name.decode("utf-8") if isinstance(name, bytes) else name
|
|
1479
|
+
for name in self.header["property_name"]
|
|
1480
|
+
if name and name.strip()
|
|
1481
|
+
]
|
|
1482
|
+
|
|
1483
|
+
# Check for per-point data (scalars)
|
|
1484
|
+
n_scalars = self.header.get("nb_scalars_per_point", 0)
|
|
1485
|
+
if n_scalars > 0:
|
|
1486
|
+
for i in range(n_scalars):
|
|
1487
|
+
if i < len(scalar_names) and scalar_names[i]:
|
|
1488
|
+
name = scalar_names[i]
|
|
1489
|
+
else:
|
|
1490
|
+
name = f"scalar_{i}"
|
|
1491
|
+
|
|
1492
|
+
properties[name] = {
|
|
1493
|
+
"type": "per_point",
|
|
1494
|
+
"data_type": "float32",
|
|
1495
|
+
"index": i,
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
# Check for per-streamline data (properties)
|
|
1499
|
+
n_properties = self.header.get("nb_properties_per_streamline", 0)
|
|
1500
|
+
if n_properties > 0:
|
|
1501
|
+
for i in range(n_properties):
|
|
1502
|
+
if i < len(property_names) and property_names[i]:
|
|
1503
|
+
name = property_names[i]
|
|
1504
|
+
else:
|
|
1505
|
+
name = f"property_{i}"
|
|
1506
|
+
|
|
1507
|
+
properties[name] = {
|
|
1508
|
+
"type": "per_streamline",
|
|
1509
|
+
"data_type": "float32",
|
|
1510
|
+
"index": i,
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
self.data_properties = properties
|
|
1514
|
+
|
|
1515
|
+
####################################################################################################
|
|
1516
|
+
def explore(self, max_streamline_samples: int = 5) -> str:
|
|
1517
|
+
"""
|
|
1518
|
+
Generate a comprehensive summary of the TRK file.
|
|
1519
|
+
|
|
1520
|
+
Parameters
|
|
1521
|
+
----------
|
|
1522
|
+
max_streamline_samples (int):
|
|
1523
|
+
Maximum number of streamlines to sample for detailed info
|
|
1524
|
+
|
|
1525
|
+
Returns
|
|
1526
|
+
str: Formatted summary string
|
|
1527
|
+
|
|
1528
|
+
Raises
|
|
1529
|
+
FileNotFoundError: If the TRK file does not exist.
|
|
1530
|
+
ValueError: If the TRK file is not in the expected format.
|
|
1531
|
+
Examples
|
|
1532
|
+
----------
|
|
1533
|
+
>>> explorer = TRKExplorer('path/to/your/file.trk')
|
|
1534
|
+
>>> summary = explorer.explore(max_streamline_samples=10)
|
|
1535
|
+
>>> print(summary)
|
|
1536
|
+
>>> # To get detailed information about streamlines and properties
|
|
1537
|
+
>>> explorer._analyze_streamlines(max_sample=5)
|
|
1538
|
+
>>> explorer._analyze_data_properties()
|
|
1539
|
+
>>> print(explorer.streamlines_info)
|
|
1540
|
+
"""
|
|
1541
|
+
self._analyze_streamlines(max_streamline_samples)
|
|
1542
|
+
self._analyze_data_properties()
|
|
1543
|
+
|
|
1544
|
+
# Get file size
|
|
1545
|
+
file_size_mb = self.filepath.stat().st_size / (1024 * 1024)
|
|
1546
|
+
|
|
1547
|
+
# Build summary
|
|
1548
|
+
summary_lines = []
|
|
1549
|
+
|
|
1550
|
+
# File header
|
|
1551
|
+
summary_lines.append(
|
|
1552
|
+
f"📁 {self.filepath.name} (TrackVis format, {file_size_mb:.1f} MB)"
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
# Header section - FIXED: Convert numpy types to Python native types
|
|
1556
|
+
summary_lines.append("├── 📋 Header")
|
|
1557
|
+
dimensions = [
|
|
1558
|
+
int(x) for x in self.header["dimensions"]
|
|
1559
|
+
] # Convert numpy types to int
|
|
1560
|
+
voxel_sizes = [
|
|
1561
|
+
round(float(x), 2) for x in self.header["voxel_sizes"]
|
|
1562
|
+
] # Convert to float first
|
|
1563
|
+
|
|
1564
|
+
summary_lines.append(f"│ ├── 🔢 dimensions {dimensions}")
|
|
1565
|
+
summary_lines.append(f"│ ├── 🔢 voxel_sizes {voxel_sizes}")
|
|
1566
|
+
summary_lines.append(
|
|
1567
|
+
f"│ ├── 📝 n_streamlines = {self.streamlines_info['total_count']:,}"
|
|
1568
|
+
)
|
|
1569
|
+
summary_lines.append(
|
|
1570
|
+
f"│ ├── 📊 n_scalars = {self.header.get('nb_scalars_per_point', 0)}"
|
|
1571
|
+
)
|
|
1572
|
+
summary_lines.append(
|
|
1573
|
+
f"│ ├── 🏷️ n_properties = {self.header.get('nb_properties_per_streamline', 0)}"
|
|
1574
|
+
)
|
|
1575
|
+
summary_lines.append(
|
|
1576
|
+
f"│ └── 📌 version = {self.header.get('version', 'unknown')}"
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
# Streamlines section
|
|
1580
|
+
if self.streamlines_info["total_count"] > 0:
|
|
1581
|
+
stats = self.streamlines_info["statistics"]
|
|
1582
|
+
summary_lines.append(
|
|
1583
|
+
f"├── 🧵 Streamlines ({self.streamlines_info['total_count']:,} total)"
|
|
1584
|
+
)
|
|
1585
|
+
summary_lines.append(
|
|
1586
|
+
f"│ ├── 📏 length range: {stats['min_length']}-{stats['max_length']} points"
|
|
1587
|
+
)
|
|
1588
|
+
summary_lines.append(
|
|
1589
|
+
f"│ ├── 📊 average length: {stats['avg_length']:.1f} points"
|
|
1590
|
+
)
|
|
1591
|
+
summary_lines.append(f"│ ├── 🔢 total points: {stats['total_points']:,}")
|
|
1592
|
+
|
|
1593
|
+
# Sample streamlines
|
|
1594
|
+
for i, sample in enumerate(self.streamlines_info["samples"]):
|
|
1595
|
+
prefix = (
|
|
1596
|
+
"│ ├──"
|
|
1597
|
+
if i < len(self.streamlines_info["samples"]) - 1
|
|
1598
|
+
else "│ └──"
|
|
1599
|
+
)
|
|
1600
|
+
summary_lines.append(
|
|
1601
|
+
f"{prefix} 📊 streamline_{sample['index']} "
|
|
1602
|
+
f"[{sample['n_points']} × 3] {sample['data_type']} "
|
|
1603
|
+
f"({sample['size_kb']:.1f} KB)"
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
if (
|
|
1607
|
+
len(self.streamlines_info["samples"])
|
|
1608
|
+
< self.streamlines_info["total_count"]
|
|
1609
|
+
):
|
|
1610
|
+
remaining = self.streamlines_info["total_count"] - len(
|
|
1611
|
+
self.streamlines_info["samples"]
|
|
1612
|
+
)
|
|
1613
|
+
summary_lines.append(f"│ 📝 ... {remaining:,} more streamlines")
|
|
1614
|
+
else:
|
|
1615
|
+
summary_lines.append("├── 🧵 Streamlines (0 total)")
|
|
1616
|
+
|
|
1617
|
+
# Data properties section
|
|
1618
|
+
if self.data_properties:
|
|
1619
|
+
summary_lines.append("└── 🏷️ Data Properties")
|
|
1620
|
+
prop_items = list(self.data_properties.items())
|
|
1621
|
+
for i, (name, info) in enumerate(prop_items):
|
|
1622
|
+
prefix = " ├──" if i < len(prop_items) - 1 else " └──"
|
|
1623
|
+
summary_lines.append(
|
|
1624
|
+
f"{prefix} 📊 {name} ({info['type']}, {info['data_type']})"
|
|
1625
|
+
)
|
|
1626
|
+
else:
|
|
1627
|
+
summary_lines.append("└── 🏷️ Data Properties (none)")
|
|
1628
|
+
|
|
1629
|
+
return "\n".join(summary_lines)
|
|
1630
|
+
|
|
1631
|
+
####################################################################################################
|
|
1632
|
+
def get_header_info(self) -> Dict[str, Any]:
|
|
1633
|
+
"""Return header information as a dictionary."""
|
|
1634
|
+
return dict(self.header)
|
|
1635
|
+
|
|
1636
|
+
####################################################################################################
|
|
1637
|
+
def get_streamlines_summary(self) -> Dict[str, Any]:
|
|
1638
|
+
"""Return streamlines summary information."""
|
|
1639
|
+
return self.streamlines_info.copy()
|
|
1640
|
+
|
|
1641
|
+
####################################################################################################
|
|
1642
|
+
def get_data_properties(self) -> Dict[str, Any]:
|
|
1643
|
+
"""Return data properties information."""
|
|
1644
|
+
return self.data_properties.copy()
|
|
1645
|
+
|
|
1646
|
+
####################################################################################################
|
|
1647
|
+
def get_streamlines(self):
|
|
1648
|
+
"""Return the actual streamlines data."""
|
|
1649
|
+
return self.trk_file.streamlines
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
#########################################################################################################
|
|
1653
|
+
def explore_trk(filepath: str, max_streamline_samples: int = 5) -> str:
|
|
1654
|
+
"""
|
|
1655
|
+
Quick function to explore a TRK file and return a summary.
|
|
1656
|
+
|
|
1657
|
+
Parameters
|
|
1658
|
+
----------
|
|
1659
|
+
filepath : str
|
|
1660
|
+
Path to the TRK file to explore.
|
|
1661
|
+
|
|
1662
|
+
max_streamline_samples : int, optional
|
|
1663
|
+
Maximum number of streamlines to sample for detailed info.
|
|
1664
|
+
Default is 5.
|
|
1665
|
+
|
|
1666
|
+
Returns
|
|
1667
|
+
-------
|
|
1668
|
+
str
|
|
1669
|
+
Formatted summary string of the TRK file.
|
|
1670
|
+
|
|
1671
|
+
Raises
|
|
1672
|
+
------
|
|
1673
|
+
FileNotFoundError
|
|
1674
|
+
If the specified TRK file does not exist.
|
|
1675
|
+
|
|
1676
|
+
ValueError
|
|
1677
|
+
If the TRK file is not in the expected format or does not contain streamlines.
|
|
1678
|
+
|
|
1679
|
+
Examples
|
|
1680
|
+
--------
|
|
1681
|
+
>>> summary = explore_trk('path/to/your/file.trk', max_streamline_samples=10)
|
|
1682
|
+
>>> print(summary)
|
|
1683
|
+
>>> # To get detailed information about streamlines and properties
|
|
1684
|
+
>>> explorer = TRKExplorer('path/to/your/file.trk')
|
|
1685
|
+
>>> explorer._analyze_streamlines(max_sample=5)
|
|
1686
|
+
>>> explorer._analyze_data_properties()
|
|
1687
|
+
>>> print(explorer.streamlines_info)
|
|
1688
|
+
>>> print(explorer.data_properties)
|
|
1689
|
+
>>> streamlines = explorer.get_streamlines()
|
|
1690
|
+
>>> header_info = explorer.get_header_info()
|
|
1691
|
+
>>> print(header_info)
|
|
1692
|
+
>>> print(streamlines)
|
|
1693
|
+
|
|
1694
|
+
"""
|
|
1695
|
+
|
|
1696
|
+
# Check if the file exists
|
|
1697
|
+
if not os.path.isfile(filepath):
|
|
1698
|
+
raise FileNotFoundError(f"TRK file not found: {filepath}")
|
|
1699
|
+
|
|
1700
|
+
explorer = TRKExplorer(filepath)
|
|
1701
|
+
|
|
1702
|
+
return print(explorer.explore(max_streamline_samples))
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
##########################################################################################################
|
|
1706
|
+
def interpolate_on_tractogram(
|
|
1707
|
+
in_tract: str,
|
|
1708
|
+
scal_map: str,
|
|
1709
|
+
out_tract: str = None,
|
|
1710
|
+
interp_method: str = "linear",
|
|
1711
|
+
storage_mode: str = "data_per_point",
|
|
1712
|
+
map_name: str = "fa",
|
|
1713
|
+
reduction: str = "mean",
|
|
1714
|
+
preserve_both_storage_modes: bool = False,
|
|
1715
|
+
):
|
|
1716
|
+
"""
|
|
1717
|
+
Interpolate scalar values (e.g., FA) from a NIfTI image onto a tractogram.
|
|
1718
|
+
|
|
1719
|
+
This function loads a tractogram and a scalar map, then interpolates the scalar
|
|
1720
|
+
values at each streamline point or aggregates them per streamline. The resulting
|
|
1721
|
+
tractogram is saved with the interpolated values attached as metadata.
|
|
1722
|
+
|
|
1723
|
+
Parameters
|
|
1724
|
+
----------
|
|
1725
|
+
in_tract : str or Path
|
|
1726
|
+
Path to input .trk tractogram file. Must exist and be readable.
|
|
1727
|
+
|
|
1728
|
+
scal_map : str or Path
|
|
1729
|
+
Path to scalar image (e.g., FA map in NIfTI format). Must exist and be readable.
|
|
1730
|
+
|
|
1731
|
+
out_tract : str or Path
|
|
1732
|
+
Path to save the new tractogram with interpolated values. The parent directory
|
|
1733
|
+
must exist and be writable.
|
|
1734
|
+
|
|
1735
|
+
interp_method : {'linear', 'nearest'}, default='linear'
|
|
1736
|
+
Interpolation method used for RegularGridInterpolator.
|
|
1737
|
+
- 'linear': Trilinear interpolation
|
|
1738
|
+
- 'nearest': Nearest neighbor interpolation
|
|
1739
|
+
|
|
1740
|
+
storage_mode : {'data_per_point', 'data_per_streamline'}, default='data_per_point'
|
|
1741
|
+
Storage format for the interpolated values:
|
|
1742
|
+
- 'data_per_point': Store scalar value for each streamline point
|
|
1743
|
+
- 'data_per_streamline': Store aggregated scalar value per streamline
|
|
1744
|
+
|
|
1745
|
+
map_name : str, default='fa'
|
|
1746
|
+
Name used for the scalar map in the output tractogram metadata.
|
|
1747
|
+
This will be the key in data_per_point or data_per_streamline.
|
|
1748
|
+
|
|
1749
|
+
reduction : {'mean', 'median', 'min', 'max'}, default='mean'
|
|
1750
|
+
Aggregation method used when storage_mode='data_per_streamline'.
|
|
1751
|
+
Applied to all scalar values along each streamline to produce a single value.
|
|
1752
|
+
|
|
1753
|
+
preserve_both_storage_modes : bool, default=False
|
|
1754
|
+
If True, preserve existing data in both data_per_point and data_per_streamline.
|
|
1755
|
+
**Warning**: This may cause visualization conflicts in some tools (FSLeyes, etc.)
|
|
1756
|
+
that have trouble rendering tractograms with both storage modes present.
|
|
1757
|
+
Use only when you specifically need both storage modes for different applications.
|
|
1758
|
+
|
|
1759
|
+
Returns
|
|
1760
|
+
-------
|
|
1761
|
+
new_tractogram : nibabel.streamlines.Tractogram
|
|
1762
|
+
The tractogram object with interpolated scalar values attached.
|
|
1763
|
+
|
|
1764
|
+
scalar_values_per_streamline : list of numpy.ndarray
|
|
1765
|
+
List containing the interpolated scalar values for each streamline.
|
|
1766
|
+
Each array has shape (n_points,) where n_points is the number of points
|
|
1767
|
+
in the corresponding streamline. Contains NaN for points outside the scalar map.
|
|
1768
|
+
|
|
1769
|
+
Raises
|
|
1770
|
+
------
|
|
1771
|
+
FileNotFoundError
|
|
1772
|
+
If input tractogram file or scalar map file does not exist.
|
|
1773
|
+
|
|
1774
|
+
NotADirectoryError
|
|
1775
|
+
If the parent directory of the output path does not exist.
|
|
1776
|
+
|
|
1777
|
+
PermissionError
|
|
1778
|
+
If the output directory is not writable.
|
|
1779
|
+
|
|
1780
|
+
ValueError
|
|
1781
|
+
If interp_method is not 'linear' or 'nearest', if storage_mode is not
|
|
1782
|
+
'data_per_point' or 'data_per_streamline', or if reduction method is
|
|
1783
|
+
not one of 'mean', 'median', 'min', 'max'.
|
|
1784
|
+
|
|
1785
|
+
IOError
|
|
1786
|
+
If there are issues reading the input files or writing the output file.
|
|
1787
|
+
|
|
1788
|
+
Notes
|
|
1789
|
+
-----
|
|
1790
|
+
- Points outside the scalar map boundaries will have NaN values
|
|
1791
|
+
- Empty streamlines are handled gracefully with empty arrays
|
|
1792
|
+
- The function preserves the original tractogram's affine transformation
|
|
1793
|
+
- When using 'data_per_streamline' mode, the map name will be suffixed
|
|
1794
|
+
with the reduction method (e.g., 'fa_mean')
|
|
1795
|
+
- **Important**: By default, the function only populates the requested
|
|
1796
|
+
storage_mode and clears the other to prevent visualization conflicts.
|
|
1797
|
+
Many tools (FSLeyes, etc.) have trouble rendering tractograms with both
|
|
1798
|
+
data_per_point and data_per_streamline present simultaneously.
|
|
1799
|
+
- Set preserve_both_storage_modes=True only if you specifically need both
|
|
1800
|
+
storage modes for different applications, but be aware of potential
|
|
1801
|
+
visualization issues.
|
|
1802
|
+
|
|
1803
|
+
Examples
|
|
1804
|
+
--------
|
|
1805
|
+
Basic usage with FA map:
|
|
1806
|
+
|
|
1807
|
+
>>> new_tract, values = interpolate_on_tractogram(
|
|
1808
|
+
... 'input.trk', 'fa_map.nii.gz', 'output_with_fa.trk',
|
|
1809
|
+
... map_name='fractional_anisotropy'
|
|
1810
|
+
... )
|
|
1811
|
+
|
|
1812
|
+
Using median aggregation per streamline:
|
|
1813
|
+
|
|
1814
|
+
>>> new_tract, values = interpolate_on_tractogram(
|
|
1815
|
+
... 'input.trk', 'md_map.nii.gz', 'output_with_md.trk',
|
|
1816
|
+
... storage_mode='data_per_streamline',
|
|
1817
|
+
... reduction='median',
|
|
1818
|
+
... map_name='mean_diffusivity'
|
|
1819
|
+
... )
|
|
1820
|
+
|
|
1821
|
+
Preserving both storage modes (use with caution):
|
|
1822
|
+
|
|
1823
|
+
>>> new_tract, values = interpolate_on_tractogram(
|
|
1824
|
+
... 'input.trk', 'fa_map.nii.gz', 'output_with_fa.trk',
|
|
1825
|
+
... preserve_both_storage_modes=True
|
|
1826
|
+
... )
|
|
1827
|
+
# Warning: May cause visualization issues in FSLeyes and other tools
|
|
1828
|
+
"""
|
|
1829
|
+
|
|
1830
|
+
# --- Input validation ---
|
|
1831
|
+
in_tract = Path(in_tract)
|
|
1832
|
+
scal_map = Path(scal_map)
|
|
1833
|
+
|
|
1834
|
+
# Check if input files exist
|
|
1835
|
+
if not in_tract.exists():
|
|
1836
|
+
raise FileNotFoundError(f"Input tractogram file not found: {in_tract}")
|
|
1837
|
+
|
|
1838
|
+
if not scal_map.exists():
|
|
1839
|
+
raise FileNotFoundError(f"Scalar map file not found: {scal_map}")
|
|
1840
|
+
|
|
1841
|
+
# Check if output directory exists
|
|
1842
|
+
if out_tract is not None:
|
|
1843
|
+
out_tract = Path(out_tract)
|
|
1844
|
+
out_dir = out_tract.parent
|
|
1845
|
+
if not out_dir.exists():
|
|
1846
|
+
raise NotADirectoryError(f"Output directory does not exist: {out_dir}")
|
|
1847
|
+
|
|
1848
|
+
# Check if output directory is writable
|
|
1849
|
+
if not os.access(out_dir, os.W_OK):
|
|
1850
|
+
raise PermissionError(f"Output directory is not writable: {out_dir}")
|
|
1851
|
+
|
|
1852
|
+
# Validate parameters
|
|
1853
|
+
valid_interp_methods = ["linear", "nearest"]
|
|
1854
|
+
if interp_method not in valid_interp_methods:
|
|
1855
|
+
raise ValueError(
|
|
1856
|
+
f"Invalid interpolation method '{interp_method}'. "
|
|
1857
|
+
f"Choose from {valid_interp_methods}"
|
|
1858
|
+
)
|
|
1859
|
+
|
|
1860
|
+
valid_storage_modes = ["data_per_point", "data_per_streamline"]
|
|
1861
|
+
if storage_mode not in valid_storage_modes:
|
|
1862
|
+
raise ValueError(
|
|
1863
|
+
f"Invalid storage mode '{storage_mode}'. "
|
|
1864
|
+
f"Choose from {valid_storage_modes}"
|
|
1865
|
+
)
|
|
1866
|
+
|
|
1867
|
+
valid_reductions = ["mean", "median", "min", "max"]
|
|
1868
|
+
if reduction not in valid_reductions:
|
|
1869
|
+
raise ValueError(
|
|
1870
|
+
f"Invalid reduction method '{reduction}'. "
|
|
1871
|
+
f"Choose from {valid_reductions}"
|
|
1872
|
+
)
|
|
1873
|
+
|
|
1874
|
+
# --- Load tractogram ---
|
|
1875
|
+
try:
|
|
1876
|
+
trk_file = nib.streamlines.load(str(in_tract))
|
|
1877
|
+
except Exception as e:
|
|
1878
|
+
raise IOError(f"Failed to load tractogram file '{in_tract}': {e}")
|
|
1879
|
+
|
|
1880
|
+
tractogram = trk_file.tractogram
|
|
1881
|
+
streamlines = tractogram.streamlines
|
|
1882
|
+
|
|
1883
|
+
if tractogram.affine_to_rasmm is not None:
|
|
1884
|
+
original_affine = tractogram.affine_to_rasmm
|
|
1885
|
+
else:
|
|
1886
|
+
original_affine = trk_file.affine
|
|
1887
|
+
|
|
1888
|
+
# --- Load scalar image ---
|
|
1889
|
+
try:
|
|
1890
|
+
scalar_img = nib.load(str(scal_map))
|
|
1891
|
+
except Exception as e:
|
|
1892
|
+
raise IOError(f"Failed to load scalar map '{scal_map}': {e}")
|
|
1893
|
+
|
|
1894
|
+
scalar_data = scalar_img.get_fdata()
|
|
1895
|
+
inv_affine = np.linalg.inv(scalar_img.affine)
|
|
1896
|
+
|
|
1897
|
+
# Creating interpolation function
|
|
1898
|
+
x = np.arange(scalar_data.shape[0])
|
|
1899
|
+
y = np.arange(scalar_data.shape[1])
|
|
1900
|
+
z = np.arange(scalar_data.shape[2])
|
|
1901
|
+
my_interpolating_scalmap = RegularGridInterpolator(
|
|
1902
|
+
(x, y, z), scalar_data, method=interp_method
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
# --- Interpolate scalar values per streamline point ---
|
|
1906
|
+
scalar_values_per_streamline = []
|
|
1907
|
+
for sl in streamlines:
|
|
1908
|
+
if len(sl) == 0:
|
|
1909
|
+
scalar_values_per_streamline.append(np.array([]))
|
|
1910
|
+
continue
|
|
1911
|
+
|
|
1912
|
+
ones = np.ones((len(sl), 1))
|
|
1913
|
+
coords_hom = np.hstack([sl, ones])
|
|
1914
|
+
voxel_coords = (inv_affine @ coords_hom.T).T[:, :3].T
|
|
1915
|
+
|
|
1916
|
+
mask = (
|
|
1917
|
+
(voxel_coords[0] >= 0)
|
|
1918
|
+
& (voxel_coords[0] < scalar_data.shape[0])
|
|
1919
|
+
& (voxel_coords[1] >= 0)
|
|
1920
|
+
& (voxel_coords[1] < scalar_data.shape[1])
|
|
1921
|
+
& (voxel_coords[2] >= 0)
|
|
1922
|
+
& (voxel_coords[2] < scalar_data.shape[2])
|
|
1923
|
+
)
|
|
1924
|
+
|
|
1925
|
+
values = np.full(voxel_coords.shape[1], np.nan)
|
|
1926
|
+
if np.any(mask):
|
|
1927
|
+
values[mask] = my_interpolating_scalmap(voxel_coords[:, mask].T)
|
|
1928
|
+
|
|
1929
|
+
scalar_values_per_streamline.append(values)
|
|
1930
|
+
|
|
1931
|
+
# --- Store scalar values ---
|
|
1932
|
+
# Handle storage mode conflicts - clear the other mode unless user explicitly wants both
|
|
1933
|
+
if preserve_both_storage_modes:
|
|
1934
|
+
# Preserve both storage modes (may cause visualization conflicts)
|
|
1935
|
+
data_per_point = (
|
|
1936
|
+
tractogram.data_per_point.copy() if tractogram.data_per_point else {}
|
|
1937
|
+
)
|
|
1938
|
+
data_per_streamline = (
|
|
1939
|
+
tractogram.data_per_streamline.copy()
|
|
1940
|
+
if tractogram.data_per_streamline
|
|
1941
|
+
else {}
|
|
1942
|
+
)
|
|
1943
|
+
print(
|
|
1944
|
+
"⚠️ Warning: Preserving both storage modes may cause visualization conflicts in some tools"
|
|
1945
|
+
)
|
|
1946
|
+
else:
|
|
1947
|
+
# Only use the requested storage mode to avoid visualization conflicts
|
|
1948
|
+
if storage_mode == "data_per_point":
|
|
1949
|
+
data_per_point = (
|
|
1950
|
+
tractogram.data_per_point.copy() if tractogram.data_per_point else {}
|
|
1951
|
+
)
|
|
1952
|
+
data_per_streamline = {} # Clear to avoid conflicts
|
|
1953
|
+
else: # storage_mode == 'data_per_streamline'
|
|
1954
|
+
data_per_point = {} # Clear to avoid conflicts
|
|
1955
|
+
data_per_streamline = (
|
|
1956
|
+
tractogram.data_per_streamline.copy()
|
|
1957
|
+
if tractogram.data_per_streamline
|
|
1958
|
+
else {}
|
|
1959
|
+
)
|
|
1960
|
+
|
|
1961
|
+
if storage_mode == "data_per_point":
|
|
1962
|
+
formatted = [
|
|
1963
|
+
val.reshape(-1, 1) if len(val) > 0 else np.empty((0, 1))
|
|
1964
|
+
for val in scalar_values_per_streamline
|
|
1965
|
+
]
|
|
1966
|
+
data_per_point[map_name] = formatted
|
|
1967
|
+
|
|
1968
|
+
elif storage_mode == "data_per_streamline":
|
|
1969
|
+
reducer = {
|
|
1970
|
+
"mean": np.nanmean,
|
|
1971
|
+
"median": np.nanmedian,
|
|
1972
|
+
"min": np.nanmin,
|
|
1973
|
+
"max": np.nanmax,
|
|
1974
|
+
}[reduction]
|
|
1975
|
+
|
|
1976
|
+
values = [
|
|
1977
|
+
reducer(v) if len(v) > 0 and not np.all(np.isnan(v)) else np.nan
|
|
1978
|
+
for v in scalar_values_per_streamline
|
|
1979
|
+
]
|
|
1980
|
+
data_per_streamline[f"{map_name}_{reduction}"] = np.array(values).reshape(-1, 1)
|
|
1981
|
+
|
|
1982
|
+
# --- Save new tractogram ---
|
|
1983
|
+
new_tractogram = nib.streamlines.Tractogram(
|
|
1984
|
+
streamlines=streamlines,
|
|
1985
|
+
data_per_point=data_per_point,
|
|
1986
|
+
data_per_streamline=data_per_streamline,
|
|
1987
|
+
affine_to_rasmm=original_affine,
|
|
1988
|
+
)
|
|
1989
|
+
|
|
1990
|
+
header = trk_file.header.copy()
|
|
1991
|
+
trk_with_header = TrkFile(new_tractogram, header=header)
|
|
1992
|
+
|
|
1993
|
+
if out_tract is not None:
|
|
1994
|
+
try:
|
|
1995
|
+
nib.streamlines.save(trk_with_header, str(out_tract))
|
|
1996
|
+
except Exception as e:
|
|
1997
|
+
raise IOError(f"Failed to save output tractogram '{out_tract}': {e}")
|
|
1998
|
+
|
|
1999
|
+
return new_tractogram, scalar_values_per_streamline
|