clabtoolkit 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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