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,2734 @@
1
+ import os
2
+ import sys
3
+ import copy
4
+ import warnings
5
+
6
+ import nibabel as nib
7
+ import numpy as np
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Union, Optional, List, Tuple
11
+ import pyvista as pv
12
+
13
+ from scipy.ndimage import binary_erosion, binary_dilation, binary_opening, convolve
14
+ from scipy.ndimage import binary_fill_holes, label, binary_closing, gaussian_filter
15
+ from scipy.spatial import distance
16
+
17
+ from skimage import measure
18
+
19
+ # Importing local modules
20
+ from . import misctools as cltmisc
21
+ from . import bidstools as cltbids
22
+
23
+
24
+ ####################################################################################################
25
+ ####################################################################################################
26
+ ############ ############
27
+ ############ ############
28
+ ############ Section 1: Class and methods to perform morphological operations on images ############
29
+ ############ ############
30
+ ############ ############
31
+ ####################################################################################################
32
+ ####################################################################################################
33
+ class MorphologicalOperations:
34
+ """
35
+ A class to perform morphological operations on binary arrays.
36
+
37
+ Provides methods for common morphological operations including erosion,
38
+ dilation, opening, closing, and hole filling on 2D and 3D binary images.
39
+ """
40
+
41
+ def __init__(self):
42
+ """Initialize the morphological operations class."""
43
+ pass
44
+
45
+ ########################################################################################################
46
+ def create_structuring_element(self, shape="cube", size=3, dimensions=None):
47
+ """
48
+ Create a structuring element for morphological operations.
49
+
50
+ Parameters
51
+ ----------
52
+ shape : str, optional
53
+ Element shape: 'cube'/'square', 'ball'/'disk', or 'cross'. Default is 'cube'.
54
+
55
+ size : int, optional
56
+ Size of the structuring element. Default is 3.
57
+
58
+ dimensions : int, optional
59
+ Number of dimensions (2 or 3). If None, defaults to 3. Default is None.
60
+
61
+ Returns
62
+ -------
63
+ np.ndarray
64
+ Boolean array representing the structuring element.
65
+
66
+ Raises
67
+ ------
68
+ ValueError
69
+ If shape is not supported or dimensions are invalid.
70
+
71
+ Examples
72
+ --------
73
+ >>> morph = MorphologicalOperations()
74
+ >>> cube_elem = morph.create_structuring_element('cube', size=5)
75
+ >>> ball_elem = morph.create_structuring_element('ball', size=7, dimensions=3)
76
+ """
77
+
78
+ if dimensions is None:
79
+ dimensions = 3 # default to 3D
80
+
81
+ if shape in ["cube", "square"]:
82
+ # Create cubic/square structuring element
83
+ return np.ones((size,) * dimensions, dtype=bool)
84
+
85
+ elif shape in ["ball", "disk"]:
86
+ # Create spherical/circular structuring element
87
+ radius = size // 2
88
+ if dimensions == 2:
89
+ y, x = np.ogrid[-radius : radius + 1, -radius : radius + 1]
90
+ return x**2 + y**2 <= radius**2
91
+ elif dimensions == 3:
92
+ z, y, x = np.ogrid[
93
+ -radius : radius + 1, -radius : radius + 1, -radius : radius + 1
94
+ ]
95
+ return x**2 + y**2 + z**2 <= radius**2
96
+ else:
97
+ raise ValueError("Ball/disk only supported for 2D and 3D")
98
+
99
+ elif shape == "cross":
100
+ # Create cross-shaped structuring element
101
+ if dimensions == 2:
102
+ cross = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool)
103
+ return cross
104
+ elif dimensions == 3:
105
+ cross = np.zeros((3, 3, 3), dtype=bool)
106
+ cross[1, 1, :] = True # x-axis
107
+ cross[1, :, 1] = True # y-axis
108
+ cross[:, 1, 1] = True # z-axis
109
+ return cross
110
+ else:
111
+ raise ValueError("Cross only supported for 2D and 3D")
112
+
113
+ else:
114
+ raise ValueError(
115
+ "Shape must be 'cube', 'ball', 'cross', 'square', or 'disk'"
116
+ )
117
+
118
+ ########################################################################################################
119
+ def erode(self, binary_array, structure=None, iterations=1):
120
+ """
121
+ Perform binary erosion to shrink objects and remove small noise.
122
+
123
+ Parameters
124
+ ----------
125
+ binary_array : np.ndarray
126
+ Binary numpy array (2D or 3D).
127
+
128
+ structure : np.ndarray, optional
129
+ Structuring element. If None, uses default 3x3 or 3x3x3 cube. Default is None.
130
+
131
+ iterations : int, optional
132
+ Number of erosion iterations. Default is 1.
133
+
134
+ Returns
135
+ -------
136
+ np.ndarray
137
+ Eroded binary array.
138
+
139
+ Examples
140
+ --------
141
+ >>> eroded = morph.erode(binary_image, iterations=2)
142
+ """
143
+
144
+ binary_array = self._ensure_binary(binary_array)
145
+
146
+ if structure is None:
147
+ structure = self.create_structuring_element("cube", 3, binary_array.ndim)
148
+
149
+ return binary_erosion(binary_array, structure=structure, iterations=iterations)
150
+
151
+ ########################################################################################################
152
+ def dilate(self, binary_array, structure=None, iterations=1):
153
+ """
154
+ Perform binary dilation to expand objects and fill small gaps.
155
+
156
+ Parameters
157
+ ----------
158
+ binary_array : np.ndarray
159
+ Binary numpy array (2D or 3D).
160
+
161
+ structure : np.ndarray, optional
162
+ Structuring element. If None, uses default 3x3 or 3x3x3 cube. Default is None.
163
+
164
+ iterations : int, optional
165
+ Number of dilation iterations. Default is 1.
166
+
167
+ Returns
168
+ -------
169
+ np.ndarray
170
+ Dilated binary array.
171
+
172
+ Examples
173
+ --------
174
+ >>> dilated = morph.dilate(binary_image, iterations=3)
175
+ """
176
+
177
+ binary_array = self._ensure_binary(binary_array)
178
+
179
+ if structure is None:
180
+ structure = self.create_structuring_element("cube", 3, binary_array.ndim)
181
+
182
+ return binary_dilation(binary_array, structure=structure, iterations=iterations)
183
+
184
+ ########################################################################################################
185
+ def opening(self, binary_array, structure=None, iterations=1):
186
+ """
187
+ Perform morphological opening (erosion followed by dilation).
188
+
189
+ Removes small objects and noise while preserving larger structures.
190
+
191
+ Parameters
192
+ ----------
193
+ binary_array : np.ndarray
194
+ Binary numpy array (2D or 3D).
195
+
196
+ structure : np.ndarray, optional
197
+ Structuring element. Default is None.
198
+
199
+ iterations : int, optional
200
+ Number of iterations. Default is 1.
201
+
202
+ Returns
203
+ -------
204
+ np.ndarray
205
+ Opened binary array.
206
+
207
+ Examples
208
+ --------
209
+ >>> cleaned = morph.opening(noisy_image, iterations=2)
210
+ """
211
+
212
+ binary_array = self._ensure_binary(binary_array)
213
+
214
+ if structure is None:
215
+ structure = self.create_structuring_element("cube", 3, binary_array.ndim)
216
+
217
+ return binary_opening(binary_array, structure=structure, iterations=iterations)
218
+
219
+ ########################################################################################################
220
+ def closing(self, binary_array, structure=None, iterations=1):
221
+ """
222
+ Perform morphological closing (dilation followed by erosion).
223
+
224
+ Fills small holes and gaps while preserving object size.
225
+
226
+ Parameters
227
+ ----------
228
+ binary_array : np.ndarray
229
+ Binary numpy array (2D or 3D).
230
+
231
+ structure : np.ndarray, optional
232
+ Structuring element. Default is None.
233
+
234
+ iterations : int, optional
235
+ Number of iterations. Default is 1.
236
+
237
+ Returns
238
+ -------
239
+ np.ndarray
240
+ Closed binary array.
241
+
242
+ Examples
243
+ --------
244
+ >>> filled = morph.closing(image_with_holes, iterations=1)
245
+ """
246
+
247
+ binary_array = self._ensure_binary(binary_array)
248
+
249
+ if structure is None:
250
+ structure = self.create_structuring_element("cube", 3, binary_array.ndim)
251
+
252
+ return binary_closing(binary_array, structure=structure, iterations=iterations)
253
+
254
+ ########################################################################################################
255
+ def fill_holes(self, binary_array, structure=None):
256
+ """
257
+ Fill holes in binary objects.
258
+
259
+ Parameters
260
+ ----------
261
+ binary_array : np.ndarray
262
+ Binary numpy array (2D or 3D).
263
+
264
+ structure : np.ndarray, optional
265
+ Structuring element for connectivity. Default is None.
266
+
267
+ Returns
268
+ -------
269
+ np.ndarray
270
+ Binary array with filled holes.
271
+
272
+ Examples
273
+ --------
274
+ >>> filled = morph.fill_holes(binary_mask)
275
+ """
276
+
277
+ binary_array = self._ensure_binary(binary_array)
278
+ return binary_fill_holes(binary_array, structure=structure)
279
+
280
+ ########################################################################################################
281
+ def remove_small_objects(self, binary_array, min_size=50):
282
+ """
283
+ Remove connected components smaller than specified size.
284
+
285
+ Parameters
286
+ ----------
287
+ binary_array : np.ndarray
288
+ Binary numpy array (2D or 3D).
289
+
290
+ min_size : int, optional
291
+ Minimum size of objects to keep in voxels/pixels. Default is 50.
292
+
293
+ Returns
294
+ -------
295
+ np.ndarray
296
+ Binary array with small objects removed.
297
+
298
+ Examples
299
+ --------
300
+ >>> cleaned = morph.remove_small_objects(binary_image, min_size=100)
301
+ """
302
+
303
+ binary_array = self._ensure_binary(binary_array)
304
+ labeled_array, num_labels = label(binary_array)
305
+
306
+ # Count voxels/pixels in each connected component
307
+ label_sizes = np.bincount(labeled_array.ravel())
308
+
309
+ # Create mask for objects to keep (size >= min_size)
310
+ keep_labels = label_sizes >= min_size
311
+ keep_labels[0] = False # Always remove background (label 0)
312
+
313
+ # Create final result
314
+ result = np.zeros_like(binary_array, dtype=bool)
315
+ for label_idx in np.where(keep_labels)[0]:
316
+ result[labeled_array == label_idx] = True
317
+
318
+ return result
319
+
320
+ ########################################################################################################
321
+ def gradient(self, binary_array, structure=None):
322
+ """
323
+ Morphological gradient (dilation - erosion) to highlight object boundaries.
324
+
325
+ Parameters
326
+ ----------
327
+ binary_array : np.ndarray
328
+ Binary numpy array (2D or 3D).
329
+
330
+ structure : np.ndarray, optional
331
+ Structuring element. Default is None.
332
+
333
+ Returns
334
+ -------
335
+ np.ndarray
336
+ Binary array containing object boundaries.
337
+
338
+ Examples
339
+ --------
340
+ >>> edges = morph.gradient(binary_object)
341
+ """
342
+
343
+ binary_array = self._ensure_binary(binary_array)
344
+
345
+ if structure is None:
346
+ structure = self.create_structuring_element("cube", 3, binary_array.ndim)
347
+
348
+ dilated = self.dilate(binary_array, structure)
349
+ eroded = self.erode(binary_array, structure)
350
+
351
+ return dilated & ~eroded # Return as boolean
352
+
353
+ ########################################################################################################
354
+ def tophat(self, binary_array, structure=None):
355
+ """
356
+ White top-hat transform (original - opening) to extract small bright structures.
357
+
358
+ Parameters
359
+ ----------
360
+ binary_array : np.ndarray
361
+ Binary numpy array (2D or 3D).
362
+
363
+ structure : np.ndarray, optional
364
+ Structuring element. Default is None.
365
+
366
+ Returns
367
+ -------
368
+ np.ndarray
369
+ Binary array containing small bright structures.
370
+
371
+ Examples
372
+ --------
373
+ >>> small_objects = morph.tophat(binary_image)
374
+ """
375
+
376
+ binary_array = self._ensure_binary(binary_array)
377
+ opened = self.opening(binary_array, structure)
378
+ return binary_array & ~opened # Return as boolean
379
+
380
+ ########################################################################################################
381
+ def blackhat(self, binary_array, structure=None):
382
+ """
383
+ Black top-hat transform (closing - original) to extract small dark structures.
384
+
385
+ Parameters
386
+ ----------
387
+ binary_array : np.ndarray
388
+ Binary numpy array (2D or 3D).
389
+
390
+ structure : np.ndarray, optional
391
+ Structuring element. Default is None.
392
+
393
+ Returns
394
+ -------
395
+ np.ndarray
396
+ Binary array containing small dark structures (holes).
397
+
398
+ Examples
399
+ --------
400
+ >>> small_holes = morph.blackhat(binary_image)
401
+ """
402
+
403
+ binary_array = self._ensure_binary(binary_array)
404
+ closed = self.closing(binary_array, structure)
405
+ return closed & ~binary_array # Return as boolean
406
+
407
+ def _ensure_binary(self, array):
408
+ """Ensure the array is binary (boolean type)."""
409
+ if array.dtype != bool:
410
+ return array != 0
411
+ return array
412
+
413
+
414
+ #####################################################################################################
415
+ # Convenience function for quick operations
416
+ def quick_morphology(binary_array, operation, **kwargs):
417
+ """
418
+ Quick access to morphological operations without creating class instance.
419
+
420
+ Parameters
421
+ ----------
422
+ binary_array : np.ndarray
423
+ Binary numpy array (2D or 3D).
424
+
425
+ operation : str
426
+ Operation name: 'erode', 'dilate', 'opening', 'closing', 'fill_holes',
427
+ 'remove_small', 'gradient', 'tophat', 'blackhat'.
428
+
429
+ **kwargs
430
+ Additional arguments for the specific operation.
431
+
432
+ Returns
433
+ -------
434
+ np.ndarray
435
+ Result of the morphological operation.
436
+
437
+ Raises
438
+ ------
439
+ ValueError
440
+ If operation is not supported.
441
+
442
+ Examples
443
+ --------
444
+ >>> # Quick erosion
445
+ >>> eroded = quick_morphology(binary_image, 'erode', iterations=2)
446
+ >>>
447
+ >>> # Quick hole filling
448
+ >>> filled = quick_morphology(binary_mask, 'fill_holes')
449
+ """
450
+ morph = MorphologicalOperations()
451
+
452
+ operation_map = {
453
+ "erode": morph.erode,
454
+ "dilate": morph.dilate,
455
+ "opening": morph.opening,
456
+ "closing": morph.closing,
457
+ "fill_holes": morph.fill_holes,
458
+ "remove_small": morph.remove_small_objects,
459
+ "gradient": morph.gradient,
460
+ "tophat": morph.tophat,
461
+ "blackhat": morph.blackhat,
462
+ }
463
+
464
+ if operation not in operation_map:
465
+ raise ValueError(f"Operation must be one of: {list(operation_map.keys())}")
466
+
467
+ return operation_map[operation](binary_array, **kwargs)
468
+
469
+
470
+ ####################################################################################################
471
+ ####################################################################################################
472
+ ############ ############
473
+ ############ ############
474
+ ############ Section 2: Methods to get attributes from the images ############
475
+ ############ ############
476
+ ############ ############
477
+ ####################################################################################################
478
+ ####################################################################################################
479
+ def get_voxel_size(affine: np.ndarray):
480
+ """
481
+ Compute voxel dimensions from NIfTI affine matrix.
482
+
483
+ Parameters
484
+ ----------
485
+ affine : np.ndarray
486
+ 4x4 affine transformation matrix from NIfTI header.
487
+
488
+ Returns
489
+ -------
490
+ tuple
491
+ Voxel sizes (voxel_x, voxel_y, voxel_z) in mm.
492
+
493
+ Examples
494
+ --------
495
+ >>> img = nib.load('image.nii.gz')
496
+ >>> vox_x, vox_y, vox_z = get_voxel_size(img.affine)
497
+ >>> print(f"Voxel size: {vox_x:.2f} x {vox_y:.2f} x {vox_z:.2f} mm")
498
+ """
499
+
500
+ # Extract voxel sizes as the magnitude of each column vector
501
+ voxel_x = np.linalg.norm(affine[:3, 0])
502
+ voxel_y = np.linalg.norm(affine[:3, 1])
503
+ voxel_z = np.linalg.norm(affine[:3, 2])
504
+ return (voxel_x, voxel_y, voxel_z)
505
+
506
+
507
+ ####################################################################################################
508
+ def get_voxel_volume(affine: np.ndarray) -> float:
509
+ """
510
+ Compute voxel dimensions from an affine matrix.
511
+
512
+ Parameters
513
+ ----------
514
+ affine : np.ndarray
515
+ 4x4 affine transformation matrix from NIfTI header.
516
+
517
+ Returns
518
+ -------
519
+ tuple
520
+ Voxel sizes (voxel_x, voxel_y, voxel_z) in mm.
521
+
522
+ Examples
523
+ --------
524
+ >>> img = nib.load('image.nii.gz')
525
+ >>> vox_x, vox_y, vox_z = get_voxel_size(img.affine)
526
+ >>> print(f"Voxel size: {vox_x:.2f} x {vox_y:.2f} x {vox_z:.2f} mm")
527
+ """
528
+
529
+ voxel_x, voxel_y, voxel_z = get_voxel_size(affine)
530
+ return voxel_x * voxel_y * voxel_z
531
+
532
+
533
+ ####################################################################################################
534
+ def get_center(affine: np.ndarray) -> tuple:
535
+ """
536
+ Compute voxel volume from NIfTI affine matrix.
537
+
538
+ Parameters
539
+ ----------
540
+ affine : np.ndarray
541
+ 4x4 affine transformation matrix from NIfTI header.
542
+
543
+ Returns
544
+ -------
545
+ float
546
+ Voxel volume in mm³.
547
+
548
+ Examples
549
+ --------
550
+ >>> img = nib.load('image.nii.gz')
551
+ >>> volume = get_voxel_volume(img.affine)
552
+ >>> print(f"Voxel volume: {volume:.3f} mm³")
553
+ """
554
+ return (affine[0, 3], affine[1, 3], affine[2, 3])
555
+
556
+
557
+ ####################################################################################################
558
+ def get_rotation_matrix(affine: np.ndarray) -> np.ndarray:
559
+ """
560
+ Extract normalized rotation matrix from affine matrix.
561
+
562
+ Parameters
563
+ ----------
564
+ affine : np.ndarray
565
+ 4x4 affine transformation matrix from NIfTI header.
566
+
567
+ Returns
568
+ -------
569
+ np.ndarray
570
+ 3x3 normalized rotation matrix (without scaling).
571
+
572
+ Examples
573
+ --------
574
+ >>> rotation = get_rotation_matrix(img.affine)
575
+ >>> print(f"Rotation matrix shape: {rotation.shape}")
576
+ """
577
+ # Extract 3x3 rotation/scaling matrix
578
+ rot_scale = affine[:3, :3]
579
+ # Normalize each column to remove scaling and keep only rotation
580
+ rotation = np.zeros_like(rot_scale)
581
+ for i in range(3):
582
+ rotation[:, i] = rot_scale[:, i] / np.linalg.norm(rot_scale[:, i])
583
+ return rotation
584
+
585
+
586
+ ####################################################################################################
587
+ def get_vox_neighbors(
588
+ coord: np.ndarray, neighborhood: str = "26", dims: str = "3", order: int = 1
589
+ ) -> np.ndarray:
590
+ """
591
+ Get neighborhood coordinates for a voxel.
592
+
593
+ Parameters
594
+ ----------
595
+ coord : np.ndarray
596
+ Coordinates of the center voxel.
597
+
598
+ neighborhood : str, optional
599
+ Neighborhood type: '6', '18', '26' for 3D or '4', '8' for 2D. Default is '26'.
600
+
601
+ dims : str, optional
602
+ Number of dimensions: '2' or '3'. Default is '3'.
603
+
604
+ order : int, optional
605
+ Order parameter (currently unused). Default is 1.
606
+
607
+ Returns
608
+ -------
609
+ np.ndarray
610
+ Array of neighbor coordinates.
611
+
612
+ Raises
613
+ ------
614
+ ValueError
615
+ If dimensions don't match coordinates or neighborhood type is invalid.
616
+
617
+ Examples
618
+ --------
619
+ >>> # Get 26-connected neighbors in 3D
620
+ >>> center = np.array([10, 15, 20])
621
+ >>> neighbors = get_vox_neighbors(center, neighborhood='26', dims='3')
622
+ >>> print(f"Found {len(neighbors)} neighbors")
623
+ """
624
+
625
+ # Check if the number of dimensions in coord supported by the supplied coordinates
626
+ if len(coord) != int(dims):
627
+ raise ValueError(
628
+ "The number of dimensions in the coordinates is not supported."
629
+ )
630
+
631
+ # Check if the number of dimensions is supported
632
+ if dims == "3":
633
+
634
+ # Check if it is a valid neighborhood
635
+ if neighborhood not in ["6", "18", "26"]:
636
+ raise ValueError("The neighborhood type is not supported.")
637
+
638
+ # Constructing the neighborhood
639
+ if neighborhood == "6":
640
+ neighbors = np.array(
641
+ [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]
642
+ )
643
+
644
+ elif neighborhood == "12":
645
+ neighbors = np.array(
646
+ [
647
+ [1, 0, 0],
648
+ [-1, 0, 0],
649
+ [0, 1, 0],
650
+ [0, -1, 0],
651
+ [0, 0, 1],
652
+ [0, 0, -1],
653
+ [1, 1, 0],
654
+ [-1, -1, 0],
655
+ [1, -1, 0],
656
+ [-1, 1, 0],
657
+ [1, 0, 1],
658
+ [-1, 0, -1],
659
+ ]
660
+ )
661
+
662
+ elif neighborhood == "18":
663
+ neighbors = np.array(
664
+ [
665
+ [1, 0, 0],
666
+ [-1, 0, 0],
667
+ [0, 1, 0],
668
+ [0, -1, 0],
669
+ [0, 0, 1],
670
+ [0, 0, -1],
671
+ [1, 1, 0],
672
+ [-1, -1, 0],
673
+ [1, -1, 0],
674
+ [-1, 1, 0],
675
+ [1, 0, 1],
676
+ [-1, 0, -1],
677
+ [1, 0, -1],
678
+ [-1, 0, 1],
679
+ [0, 1, 1],
680
+ [0, -1, -1],
681
+ [0, 1, -1],
682
+ [0, -1, 1],
683
+ ]
684
+ )
685
+
686
+ elif neighborhood == "26":
687
+ neighbors = np.array(
688
+ [
689
+ [1, 0, 0],
690
+ [-1, 0, 0],
691
+ [0, 1, 0],
692
+ [0, -1, 0],
693
+ [0, 0, 1],
694
+ [0, 0, -1],
695
+ [1, 1, 0],
696
+ [-1, -1, 0],
697
+ [1, -1, 0],
698
+ [-1, 1, 0],
699
+ [1, 0, 1],
700
+ [-1, 0, -1],
701
+ [1, 0, -1],
702
+ [-1, 0, 1],
703
+ [0, 1, 1],
704
+ [0, -1, -1],
705
+ [0, 1, -1],
706
+ [0, -1, 1],
707
+ [1, 1, 1],
708
+ [-1, -1, -1],
709
+ [1, -1, -1],
710
+ [-1, 1, -1],
711
+ [1, 1, -1],
712
+ [-1, -1, 1],
713
+ [1, -1, 1],
714
+ [-1, 1, 1],
715
+ ]
716
+ )
717
+ elif dims == "2":
718
+
719
+ if neighborhood not in ["4", "8"]:
720
+ raise ValueError("The neighborhood type is not supported.")
721
+
722
+ if neighborhood == "4":
723
+ neighbors = np.array([[1, 0], [-1, 0], [0, 1], [0, -1]])
724
+ elif neighborhood == "8":
725
+ neighbors = np.array(
726
+ [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]]
727
+ )
728
+
729
+ else:
730
+ raise ValueError("The number of dimensions is not supported.")
731
+
732
+ neighbors = np.array([coord + n for n in neighbors])
733
+
734
+ return neighbors
735
+
736
+
737
+ ####################################################################################################
738
+ ####################################################################################################
739
+ ############ ############
740
+ ############ ############
741
+ ############ Section 3: Methods to operate over images (e.g. crop) ############
742
+ ############ ############
743
+ ############ ############
744
+ ####################################################################################################
745
+ ####################################################################################################
746
+ def crop_image_from_mask(
747
+ in_image: str,
748
+ mask: Union[str, np.ndarray],
749
+ out_image: str,
750
+ st_codes: Union[list, np.ndarray] = None,
751
+ ) -> str:
752
+ """
753
+ Crop image using a mask to minimum bounding box containing specified structures.
754
+
755
+ Parameters
756
+ ----------
757
+ in_image : str
758
+ Path to input image file.
759
+
760
+ mask : str or np.ndarray
761
+ Path to mask file or mask array. Can be binary or multi-label.
762
+
763
+ out_image : str
764
+ Path for output cropped image.
765
+
766
+ st_codes : list or np.ndarray, optional
767
+ Structure codes to include in cropping. If None, uses all non-zero values.
768
+ Default is None.
769
+
770
+ Returns
771
+ -------
772
+ str
773
+ Path to the created output image.
774
+
775
+ Raises
776
+ ------
777
+ ValueError
778
+ If input parameters are invalid or files don't exist.
779
+
780
+ Examples
781
+ --------
782
+ >>> # Crop using binary mask
783
+ >>> output = crop_image_from_mask(
784
+ ... 'brain.nii.gz', 'mask.nii.gz', 'cropped_brain.nii.gz'
785
+ ... )
786
+ >>>
787
+ >>> # Crop specific structures
788
+ >>> output = crop_image_from_mask(
789
+ ... 'image.nii.gz', 'segmentation.nii.gz', 'cropped.nii.gz',
790
+ ... st_codes=[1, 2, 3]
791
+ ... )
792
+ """
793
+
794
+ if isinstance(in_image, str) == False:
795
+ raise ValueError("The 'image' parameter must be a string.")
796
+
797
+ if isinstance(mask, str):
798
+ if not os.path.exists(mask):
799
+ raise ValueError("The 'mask' parameter must be a string.")
800
+ else:
801
+ mask = nib.load(mask)
802
+ mask_data = mask.get_fdata()
803
+ elif isinstance(mask, np.ndarray):
804
+ mask_data = mask
805
+ else:
806
+ raise ValueError("The 'mask' parameter must be a string or a numpy array.")
807
+
808
+ if st_codes is None:
809
+ st_codes = np.unique(mask_data)
810
+ st_codes = st_codes[st_codes != 0]
811
+
812
+ st_codes = cltmisc.build_indices(st_codes)
813
+ st_codes = np.array(st_codes)
814
+
815
+ # Create the output directory if it does not exist
816
+ out_pth = os.path.dirname(out_image)
817
+ if os.path.exists(out_pth) == False:
818
+ Path(out_pth).mkdir(parents=True, exist_ok=True)
819
+
820
+ # Loading both images
821
+ img1 = nib.load(in_image) # Original MRI image
822
+
823
+ # Get data and affine matrices
824
+ img1_affine = img1.affine
825
+
826
+ # Get the destination shape
827
+ img1_data = img1.get_fdata()
828
+ img1_shape = img1_data.shape
829
+
830
+ # Finding the minimum and maximum indexes for the mask
831
+ tmask = np.isin(mask_data, st_codes)
832
+ tmp_var = np.argwhere(tmask)
833
+
834
+ # Minimum and maximum indexes for X axis
835
+ i_start = np.min(tmp_var[:, 0])
836
+ i_end = np.max(tmp_var[:, 0])
837
+
838
+ # Minimum and maximum indexes for Y axis
839
+ j_start = np.min(tmp_var[:, 1])
840
+ j_end = np.max(tmp_var[:, 1])
841
+
842
+ # Minimum and maximum indexes for Z axis
843
+ k_start = np.min(tmp_var[:, 2])
844
+ k_end = np.max(tmp_var[:, 2])
845
+
846
+ # If img1_data is a 4D array we need to multiply it by the mask in the last dimension only. If not, we multiply it by the mask
847
+ # Applying the mask
848
+ if len(img1_data.shape) == 4:
849
+ masked_data = img1_data * tmask[..., np.newaxis]
850
+ else:
851
+ masked_data = img1_data * tmask
852
+
853
+ # Creating a new Nifti image with the same affine and header as img1
854
+ array_img = nib.Nifti1Image(masked_data, img1_affine)
855
+
856
+ # Cropping the masked data
857
+ if len(img1_data.shape) == 4:
858
+ cropped_img = array_img.slicer[i_start:i_end, j_start:j_end, k_start:k_end, :]
859
+ else:
860
+ cropped_img = array_img.slicer[i_start:i_end, j_start:j_end, k_start:k_end]
861
+
862
+ # Saving the cropped image
863
+ nib.save(cropped_img, out_image)
864
+
865
+ return out_image
866
+
867
+
868
+ ####################################################################################################
869
+ def cropped_to_native(in_image: str, native_image: str, out_image: str) -> str:
870
+ """
871
+ Restore cropped image to dimensions of reference native image.
872
+
873
+ Parameters
874
+ ----------
875
+ in_image : str
876
+ Path to cropped image file.
877
+
878
+ native_image : str
879
+ Path to reference image defining target dimensions.
880
+
881
+ out_image : str
882
+ Path for output restored image.
883
+
884
+ Returns
885
+ -------
886
+ str
887
+ Path to the created output image.
888
+
889
+ Raises
890
+ ------
891
+ ValueError
892
+ If input parameters are not strings.
893
+
894
+ Examples
895
+ --------
896
+ >>> # Restore cropped image to original dimensions
897
+ >>> restored = cropped_to_native(
898
+ ... 'cropped_result.nii.gz',
899
+ ... 'original.nii.gz',
900
+ ... 'restored_result.nii.gz'
901
+ ... )
902
+ """
903
+
904
+ if isinstance(in_image, str) == False:
905
+ raise ValueError("The 'in_image' parameter must be a string.")
906
+
907
+ if isinstance(native_image, str) == False:
908
+ raise ValueError("The 'native_image' parameter must be a string.")
909
+
910
+ # Create the output directory if it does not exist
911
+ out_pth = os.path.dirname(out_image)
912
+ if os.path.exists(out_pth) == False:
913
+ Path(out_pth).mkdir(parents=True, exist_ok=True)
914
+
915
+ # Loading both images
916
+ img1 = nib.load(native_image) # Original MRI image
917
+ img2 = nib.load(in_image) # Cropped image
918
+
919
+ # Get data and affine matrices
920
+ img1_affine = img1.affine
921
+ img2_affine = img2.affine
922
+
923
+ # Get the destination shape
924
+ img1_data = img1.get_fdata()
925
+ img1_shape = img1_data.shape
926
+
927
+ # Get data from IM2
928
+ img2_data = img2.get_fdata()
929
+ img2_shape = img2_data.shape
930
+
931
+ # Multiply the inverse of the affine matrix of img1 by the affine matrix of img2
932
+ affine_mult = np.linalg.inv(img1_affine) @ img2_affine
933
+
934
+ # If the img2 is a 4D add the forth dimension to the shape of the img1
935
+ if len(img2_shape) == 4:
936
+ img1_shape = (img1_shape[0], img1_shape[1], img1_shape[2], img2_shape[3])
937
+
938
+ # Create an empty array with the same dimensions as IM1
939
+ new_data = np.zeros(img1_shape, dtype=img2_data.dtype)
940
+
941
+ for vol in range(img2_data.shape[-1]):
942
+ # Find the coordinates in voxels of the voxels different from 0 on the img2
943
+ indices = np.argwhere(img2_data[..., vol] != 0)
944
+
945
+ # Apply the affine transformation to the coordinates of the voxels different from 0 on img2
946
+ new_coords = np.round(
947
+ affine_mult
948
+ @ np.concatenate((indices.T, np.ones((1, indices.shape[0]))), axis=0)
949
+ ).astype(int)
950
+
951
+ # Fill the new image with the values of the voxels different from 0 on img2
952
+ new_data[new_coords[0], new_coords[1], new_coords[2], vol] = img2_data[
953
+ indices[:, 0], indices[:, 1], indices[:, 2], vol
954
+ ]
955
+
956
+ elif len(img2_shape) == 3:
957
+ # Create an empty array with the same dimensions as IM1
958
+ new_data = np.zeros(img1_shape, dtype=img2_data.dtype)
959
+
960
+ # Find the coordinates in voxels of the voxels different from 0 on the img2
961
+ indices = np.argwhere(img2_data != 0)
962
+
963
+ # Apply the affine transformation to the coordinates of the voxels different from 0 on img2
964
+ new_coords = np.round(
965
+ affine_mult
966
+ @ np.concatenate((indices.T, np.ones((1, indices.shape[0]))), axis=0)
967
+ ).astype(int)
968
+
969
+ # Fill the new image with the values of the voxels different from 0 on img2
970
+ new_data[new_coords[0], new_coords[1], new_coords[2]] = img2_data[
971
+ indices[:, 0], indices[:, 1], indices[:, 2]
972
+ ]
973
+
974
+ # Create a new Nifti image with the same affine and header as IM1
975
+ new_img2 = nib.Nifti1Image(new_data, affine=img1_affine, header=img1.header)
976
+
977
+ # Save the new image
978
+ nib.save(new_img2, out_image)
979
+
980
+ return out_image
981
+
982
+
983
+ ####################################################################################################
984
+ ####################################################################################################
985
+ ############ ############
986
+ ############ ############
987
+ ############ Section 4: Methods to apply transformations or changes of space ############
988
+ ############ ############
989
+ ############ ############
990
+ ####################################################################################################
991
+ ####################################################################################################
992
+ def apply_multi_transf(
993
+ in_image: str,
994
+ out_image: str,
995
+ ref_image: str,
996
+ xfm_output,
997
+ interp_order: int = 0,
998
+ invert: bool = False,
999
+ cont_tech: str = "local",
1000
+ cont_image: str = None,
1001
+ force: bool = False,
1002
+ ) -> None:
1003
+ """
1004
+ Apply ANTs transformation to image with support for multiple transform types.
1005
+
1006
+ Parameters
1007
+ ----------
1008
+ in_image : str
1009
+ Path to input image.
1010
+
1011
+ out_image : str
1012
+ Path for transformed output image.
1013
+
1014
+ ref_image : str
1015
+ Path to reference image defining target space.
1016
+
1017
+ xfm_output : str
1018
+ Path to transformation files (supports affine and nonlinear).
1019
+
1020
+ interp_order : int, optional
1021
+ Interpolation method: 0=NearestNeighbor, 1=Linear, 2=BSpline, etc.
1022
+ Default is 0.
1023
+
1024
+ invert : bool, optional
1025
+ Whether to invert the transformation. Default is False.
1026
+
1027
+ cont_tech : str, optional
1028
+ Container technology: 'local', 'singularity', 'docker'. Default is 'local'.
1029
+
1030
+ cont_image : str, optional
1031
+ Container image specification. Default is None.
1032
+
1033
+ force : bool, optional
1034
+ Force recomputation if output exists. Default is False.
1035
+
1036
+ Examples
1037
+ --------
1038
+ >>> # Apply transformation with nearest neighbor interpolation
1039
+ >>> apply_multi_transf(
1040
+ ... 'input.nii.gz', 'output.nii.gz', 'template.nii.gz',
1041
+ ... 'transform_prefix', interp_order=0
1042
+ ... )
1043
+ """
1044
+
1045
+ # Check if the path of out_basename exists
1046
+ out_path = os.path.dirname(out_image)
1047
+ if not os.path.isdir(out_path):
1048
+ os.makedirs(out_path)
1049
+
1050
+ if interp_order == 0:
1051
+ interp_cad = "NearestNeighbor"
1052
+ elif interp_order == 1:
1053
+ interp_cad = "Linear"
1054
+ elif interp_order == 2:
1055
+ interp_cad = "BSpline[3]"
1056
+ elif interp_order == 3:
1057
+ interp_cad = "CosineWindowedSinc"
1058
+ elif interp_order == 4:
1059
+ interp_cad = "WelchWindowedSinc"
1060
+ elif interp_order == 5:
1061
+ interp_cad = "HammingWindowedSinc"
1062
+ elif interp_order == 6:
1063
+ interp_cad = "LanczosWindowedSinc"
1064
+ elif interp_order == 7:
1065
+ interp_cad = "Welch"
1066
+
1067
+ ######## -- Registration to the template space ------------ #
1068
+ # Creating spatial transformation folder
1069
+ stransf_dir = Path(os.path.dirname(xfm_output))
1070
+ stransf_name = os.path.basename(xfm_output)
1071
+
1072
+ if stransf_name.endswith(".nii.gz"):
1073
+ stransf_name = stransf_name[:-7]
1074
+ elif stransf_name.endswith(".nii") or stransf_name.endswith(".mat"):
1075
+ stransf_name = stransf_name[:-4]
1076
+
1077
+ if stransf_name.endswith("_xfm"):
1078
+ stransf_name = stransf_name[:-4]
1079
+
1080
+ if "_desc-" in stransf_name:
1081
+ affine_name = cltbids.replace_entity_value(stransf_name, {"desc": "affine"})
1082
+ nl_name = cltbids.replace_entity_value(stransf_name, {"desc": "warp"})
1083
+ invnl_name = cltbids.replace_entity_value(stransf_name, {"desc": "iwarp"})
1084
+ else:
1085
+ affine_name = stransf_name + "_desc-affine"
1086
+ nl_name = stransf_name + "_desc-warp"
1087
+ invnl_name = stransf_name + "_desc-iwarp"
1088
+
1089
+ affine_transf = os.path.join(stransf_dir, affine_name + "_xfm.mat")
1090
+ nl_transf = os.path.join(stransf_dir, nl_name + "_xfm.nii.gz")
1091
+ invnl_transf = os.path.join(stransf_dir, invnl_name + "_xfm.nii.gz")
1092
+
1093
+ # Check if out_image is not computed and force is True
1094
+ if not os.path.isfile(out_image) or force:
1095
+
1096
+ if not os.path.isfile(affine_transf):
1097
+ print("The spatial transformation file does not exist.")
1098
+ sys.exit()
1099
+
1100
+ if os.path.isfile(invnl_transf) and os.path.isfile(nl_transf):
1101
+ if invert:
1102
+ bashargs_transforms = [
1103
+ "-t",
1104
+ invnl_transf,
1105
+ "-t",
1106
+ "[" + affine_transf + ",1]",
1107
+ ]
1108
+ else:
1109
+ bashargs_transforms = ["-t", nl_transf, "-t", affine_transf]
1110
+ else:
1111
+ if invert:
1112
+ bashargs_transforms = ["-t", "[" + affine_transf + ",1]"]
1113
+ else:
1114
+ bashargs_transforms = ["-t", affine_transf]
1115
+
1116
+ # Creating the command
1117
+ cmd_bashargs = [
1118
+ "antsApplyTransforms",
1119
+ "-e",
1120
+ "3",
1121
+ "-i",
1122
+ in_image,
1123
+ "-r",
1124
+ ref_image,
1125
+ "-o",
1126
+ out_image,
1127
+ "-n",
1128
+ interp_cad,
1129
+ ]
1130
+ cmd_bashargs.extend(bashargs_transforms)
1131
+
1132
+ # Running containerization
1133
+ cmd_cont = cltmisc.generate_container_command(
1134
+ cmd_bashargs, cont_tech, cont_image
1135
+ ) # Generating container command
1136
+ out_cmd = subprocess.run(
1137
+ cmd_cont, stdout=subprocess.PIPE, universal_newlines=True
1138
+ )
1139
+
1140
+
1141
+ ####################################################################################################
1142
+ def vox2mm(vox_coords, affine) -> np.ndarray:
1143
+ """
1144
+ Convert voxel coordinates to millimeter coordinates using affine matrix.
1145
+
1146
+ Parameters
1147
+ ----------
1148
+ vox_coords : np.ndarray
1149
+ Matrix with voxel coordinates (N x 3).
1150
+
1151
+ affine : np.ndarray
1152
+ 4x4 affine transformation matrix.
1153
+
1154
+ Returns
1155
+ -------
1156
+ np.ndarray
1157
+ Matrix with millimeter coordinates (N x 3).
1158
+
1159
+ Raises
1160
+ ------
1161
+ ValueError
1162
+ If input matrix doesn't have 3 columns.
1163
+
1164
+ Examples
1165
+ --------
1166
+ >>> # Convert voxel coordinates to mm
1167
+ >>> vox_coords = np.array([[10, 20, 30], [15, 25, 35]])
1168
+ >>> mm_coords = vox2mm(vox_coords, img.affine)
1169
+ >>> print(f"MM coordinates: {mm_coords}")
1170
+ """
1171
+
1172
+ # Detect if the number of rows is bigger than the number of columns. If not, transpose the matrix
1173
+ nrows = np.shape(vox_coords)[0]
1174
+ ncols = np.shape(vox_coords)[1]
1175
+ if (nrows < ncols) and (ncols > 3):
1176
+ vox_coords = np.transpose(vox_coords)
1177
+
1178
+ if np.shape(vox_coords)[1] == 3:
1179
+ npoints = np.shape(vox_coords)
1180
+ vox_coords = np.c_[vox_coords, np.full(npoints[0], 1)]
1181
+
1182
+ mm_coords = np.matmul(affine, vox_coords.T)
1183
+ mm_coords = np.transpose(mm_coords)
1184
+ mm_coords = mm_coords[:, :3]
1185
+
1186
+ else:
1187
+ # Launch an error if the number of columns is different from 3
1188
+ raise ValueError("The number of columns of the input matrix must be 3")
1189
+
1190
+ return mm_coords
1191
+
1192
+
1193
+ ####################################################################################################
1194
+ def mm2vox(mm_coords, affine) -> np.ndarray:
1195
+ """
1196
+ Convert millimeter coordinates to voxel coordinates using affine matrix.
1197
+
1198
+ Parameters
1199
+ ----------
1200
+ mm_coords : np.ndarray
1201
+ Matrix with millimeter coordinates (N x 3).
1202
+
1203
+ affine : np.ndarray
1204
+ 4x4 affine transformation matrix.
1205
+
1206
+ Returns
1207
+ -------
1208
+ np.ndarray
1209
+ Matrix with voxel coordinates (N x 3).
1210
+
1211
+ Raises
1212
+ ------
1213
+ ValueError
1214
+ If input matrix doesn't have 3 columns.
1215
+
1216
+ Examples
1217
+ --------
1218
+ >>> # Convert mm coordinates to voxels
1219
+ >>> mm_coords = np.array([[45.5, -12.3, 78.9]])
1220
+ >>> vox_coords = mm2vox(mm_coords, img.affine)
1221
+ >>> print(f"Voxel coordinates: {vox_coords}")
1222
+ """
1223
+
1224
+ # Detect if the number of rows is bigger than the number of columns. If not, transpose the matrix
1225
+ nrows = np.shape(mm_coords)[0]
1226
+ ncols = np.shape(mm_coords)[1]
1227
+ if (nrows < ncols) and (ncols > 3):
1228
+ mm_coords = np.transpose(mm_coords)
1229
+
1230
+ if np.shape(mm_coords)[1] == 3:
1231
+ npoints = np.shape(mm_coords)
1232
+ mm_coords = np.c_[mm_coords, np.full(npoints[0], 1)]
1233
+
1234
+ vox_coords = np.matmul(affine, mm_coords.T)
1235
+ vox_coords = np.transpose(vox_coords)
1236
+ vox_coords = vox_coords[:, :3]
1237
+
1238
+ else:
1239
+ # Launch an error if the number of columns is different from 3
1240
+ raise ValueError("The number of columns of the input matrix must be 3")
1241
+
1242
+ return vox_coords
1243
+
1244
+
1245
+ ####################################################################################################
1246
+ ####################################################################################################
1247
+ ############ ############
1248
+ ############ ############
1249
+ ############ Section 5: Methods to work with 4D Images ############
1250
+ ############ ############
1251
+ ############ ############
1252
+ ####################################################################################################
1253
+ ####################################################################################################
1254
+ def spams2maxprob(
1255
+ spam_image: str,
1256
+ prob_thresh: float = 0.05,
1257
+ vol_indexes: np.array = None,
1258
+ maxp_name: str = None,
1259
+ ):
1260
+ """
1261
+ Convert SPAM probability maps to maximum probability parcellation.
1262
+
1263
+ Transforms 4D spatial probability maps into discrete 3D parcellation by
1264
+ selecting the most probable label at each voxel, with optional thresholding
1265
+ and volume selection.
1266
+
1267
+ Parameters
1268
+ ----------
1269
+ spam_image : str
1270
+ Path to 4D SPAM image file with probability maps for each region.
1271
+
1272
+ prob_thresh : float, optional
1273
+ Minimum probability threshold. Values below this are set to zero.
1274
+ Default is 0.05.
1275
+
1276
+ vol_indexes : np.ndarray, optional
1277
+ Indices of volumes (regions) to include. If None, uses all volumes.
1278
+ Default is None.
1279
+
1280
+ maxp_name : str, optional
1281
+ Output file path for maximum probability image. If None, returns
1282
+ array without saving. Default is None.
1283
+
1284
+ Returns
1285
+ -------
1286
+ str or np.ndarray
1287
+ Output file path if maxp_name provided, otherwise numpy array
1288
+ with maximum probability labels.
1289
+
1290
+ Notes
1291
+ -----
1292
+ The conversion process:
1293
+ 1. Applies probability threshold to remove low-confidence voxels
1294
+ 2. Optionally filters to specific volume indices
1295
+ 3. Finds maximum probability across volumes for each voxel
1296
+ 4. Assigns winner-take-all labels (1-indexed)
1297
+ 5. Sets background where no probabilities exceed threshold
1298
+
1299
+ Useful for converting probabilistic atlases to discrete parcellations
1300
+ for analysis requiring discrete labels.
1301
+
1302
+ Examples
1303
+ --------
1304
+ >>> # Convert SPAM to discrete parcellation
1305
+ >>> maxprob_file = spams2maxprob(
1306
+ ... 'AAL_SPAM.nii.gz',
1307
+ ... prob_thresh=0.1,
1308
+ ... maxp_name='AAL_discrete.nii.gz'
1309
+ ... )
1310
+ >>>
1311
+ >>> # Get array without saving, specific regions only
1312
+ >>> selected_regions = np.array([0, 1, 2, 5, 6]) # Volume indices
1313
+ >>> maxprob_array = spams2maxprob(
1314
+ ... 'probabilistic_atlas.nii.gz',
1315
+ ... vol_indexes=selected_regions,
1316
+ ... prob_thresh=0.2
1317
+ ... )
1318
+ >>> print(f"Max label: {maxprob_array.max()}")
1319
+ """
1320
+
1321
+ spam_img = nib.load(spam_image)
1322
+ affine = spam_img.affine
1323
+ spam_vol = spam_img.get_fdata()
1324
+
1325
+ spam_vol[spam_vol < prob_thresh] = 0
1326
+ spam_vol[spam_vol > 1] = 1
1327
+
1328
+ if vol_indexes is not None:
1329
+ # Creating the maxprob
1330
+
1331
+ # I want to find the complementary indexes to vol_indexes
1332
+ all_indexes = np.arange(0, spam_vol.shape[3])
1333
+ set1 = set(all_indexes)
1334
+ set2 = set(vol_indexes)
1335
+
1336
+ # Find the symmetric difference
1337
+ diff_elements = set1.symmetric_difference(set2)
1338
+
1339
+ # Convert the result back to a NumPy array if needed
1340
+ diff_array = np.array(list(diff_elements))
1341
+ spam_vol[:, :, :, diff_array] = 0
1342
+ # array_data = np.delete(spam_vol, diff_array, 3)
1343
+
1344
+ ind = np.where(np.sum(spam_vol, axis=3) == 0)
1345
+ maxprob_thl = spam_vol.argmax(axis=3) + 1
1346
+ maxprob_thl[ind] = 0
1347
+
1348
+ if maxp_name is not None:
1349
+ # Save the image
1350
+ imgcoll = nib.Nifti1Image(maxprob_thl.astype("int16"), affine)
1351
+ nib.save(imgcoll, maxp_name)
1352
+ else:
1353
+ maxp_name = maxprob_thl
1354
+
1355
+ return maxp_name
1356
+
1357
+
1358
+ #####################################################################################################
1359
+ def simulate_image(
1360
+ input_image: Union[str, nib.Nifti1Image],
1361
+ simulated_image: str = None,
1362
+ n_volumes: int = 3,
1363
+ distribution: str = "normal",
1364
+ random_seed: Optional[int] = None,
1365
+ **dist_params,
1366
+ ) -> nib.Nifti1Image:
1367
+ """
1368
+ Generate a simulated image with random values at non-zero voxel positions.
1369
+
1370
+ This function creates a new NIfTI image where non-zero voxels from the input
1371
+ image are filled with random values following a specified statistical distribution.
1372
+ The output preserves the spatial dimensions, affine transformation, and header
1373
+ information from the input image.
1374
+
1375
+ This is useful for simulating functional or structural images based on a mask
1376
+ or anatomical image, allowing for controlled random value generation in specific
1377
+ regions of interest.
1378
+
1379
+ Parameters
1380
+ ----------
1381
+ input_image : str or nibabel.Nifti1Image
1382
+ Input image used as a mask. Can be either a file path to a NIfTI image
1383
+ or a nibabel Nifti1Image object.
1384
+
1385
+ simulated_image : str
1386
+ Output file path where the simulated image will be saved. Must include
1387
+ the .nii or .nii.gz extension. If None, the function will generate a
1388
+ default name based on the input image.
1389
+
1390
+ n_volumes : int, default=3
1391
+ Number of volumes in the output image:
1392
+ - If n_volumes == 1: creates a 3D image
1393
+ - If n_volumes > 1: creates a 4D image with n_volumes timepoints
1394
+
1395
+ distribution : str, default='normal'
1396
+ Statistical distribution for random value generation. Supported options:
1397
+ - 'normal': Normal (Gaussian) distribution
1398
+ - 'uniform': Uniform distribution
1399
+ - 'exponential': Exponential distribution
1400
+
1401
+ random_seed : int, optional
1402
+ Random seed for reproducible results. If None, uses system time.
1403
+
1404
+ **dist_params : dict
1405
+ Distribution-specific parameters:
1406
+ - For 'normal': loc (mean, default=0), scale (std, default=1)
1407
+ - For 'uniform': low (default=0), high (default=1)
1408
+ - For 'exponential': scale (default=1)
1409
+
1410
+ Returns
1411
+ -------
1412
+ nibabel.Nifti1Image
1413
+ The simulated image object with the same spatial properties as input.
1414
+
1415
+ Raises
1416
+ ------
1417
+ FileNotFoundError
1418
+ If input_image path does not exist.
1419
+
1420
+ ValueError
1421
+ If input_image is not a valid type, distribution is unsupported,
1422
+ n_volumes is invalid, or output directory doesn't exist.
1423
+
1424
+ RuntimeError
1425
+ If no non-zero voxels are found in the input image.
1426
+
1427
+ Examples
1428
+ --------
1429
+ >>> # Create 3D simulation with normal distribution
1430
+ >>> sim_img = simulate_image(
1431
+ ... 'brain_mask.nii.gz',
1432
+ ... 'output_3d.nii.gz',
1433
+ ... n_volumes=1,
1434
+ ... distribution='normal',
1435
+ ... loc=0,
1436
+ ... scale=1,
1437
+ ... random_seed=42
1438
+ ... )
1439
+
1440
+ >>> # Create 4D simulation with uniform distribution
1441
+ >>> sim_img = simulate_image(
1442
+ ... 'brain_mask.nii.gz',
1443
+ ... 'output_4d.nii.gz',
1444
+ ... n_volumes=10,
1445
+ ... distribution='uniform',
1446
+ ... low=0,
1447
+ ... high=100
1448
+ ... )
1449
+
1450
+ Notes
1451
+ -----
1452
+ - Only voxels with non-zero values in the input image will contain random values
1453
+ - All other voxels remain zero in the output
1454
+ - The function preserves the input image's affine transformation and header
1455
+ - Output file extension should be .nii or .nii.gz
1456
+ """
1457
+
1458
+ # Input validation
1459
+ if not isinstance(n_volumes, int) or n_volumes < 1:
1460
+ raise ValueError("n_volumes must be a positive integer")
1461
+
1462
+ if distribution not in ["normal", "uniform", "exponential"]:
1463
+ raise ValueError(
1464
+ f"Unsupported distribution '{distribution}'. "
1465
+ "Supported: 'normal', 'uniform', 'exponential'"
1466
+ )
1467
+
1468
+ # Set random seed if provided
1469
+ if random_seed is not None:
1470
+ np.random.seed(random_seed)
1471
+
1472
+ # Load and validate input image
1473
+ if isinstance(input_image, str):
1474
+ if not os.path.exists(input_image):
1475
+ raise FileNotFoundError(f"Input image file not found: {input_image}")
1476
+ try:
1477
+ input_img = nib.load(input_image)
1478
+ except Exception as e:
1479
+ raise ValueError(f"Failed to load input image: {e}")
1480
+
1481
+ elif isinstance(input_image, nib.Nifti1Image):
1482
+ input_img = input_image
1483
+ else:
1484
+ raise ValueError(
1485
+ "input_image must be a file path (str) or nibabel.Nifti1Image object"
1486
+ )
1487
+
1488
+ # Validate simulated_image path. If None, create a temporary filename
1489
+ if simulated_image is None:
1490
+ simulated_image = cltmisc.create_temporary_filename()
1491
+
1492
+ # Validate output path
1493
+ output_dir = os.path.dirname(os.path.abspath(simulated_image))
1494
+ if output_dir and not os.path.exists(output_dir):
1495
+ raise ValueError(f"Output directory does not exist: {output_dir}")
1496
+
1497
+ if not simulated_image.endswith((".nii", ".nii.gz")):
1498
+ warnings.warn(
1499
+ "Output filename should have .nii or .nii.gz extension", UserWarning
1500
+ )
1501
+
1502
+ # Extract image properties
1503
+ input_data = input_img.get_fdata()
1504
+ affine = input_img.affine.copy()
1505
+ header = input_img.header.copy()
1506
+ original_shape = input_data.shape
1507
+
1508
+ # Create mask for non-zero voxels
1509
+ mask = input_data != 0
1510
+ n_nonzero_voxels = np.sum(mask)
1511
+
1512
+ if n_nonzero_voxels == 0:
1513
+ raise RuntimeError("No non-zero voxels found in input image")
1514
+
1515
+ # Distribution parameter validation and random value generation
1516
+ def _generate_random_values(size: int) -> np.ndarray:
1517
+ """Generate random values based on specified distribution."""
1518
+ try:
1519
+ if distribution == "normal":
1520
+ loc = dist_params.get("loc", 0.0)
1521
+ scale = dist_params.get("scale", 1.0)
1522
+ if scale <= 0:
1523
+ raise ValueError(
1524
+ "Scale parameter for normal distribution must be positive"
1525
+ )
1526
+ return np.random.normal(loc, scale, size)
1527
+
1528
+ elif distribution == "uniform":
1529
+ low = dist_params.get("low", 0.0)
1530
+ high = dist_params.get("high", 1.0)
1531
+ if low >= high:
1532
+ raise ValueError(
1533
+ "Low parameter must be less than high parameter for uniform distribution"
1534
+ )
1535
+ return np.random.uniform(low, high, size)
1536
+
1537
+ elif distribution == "exponential":
1538
+ scale = dist_params.get("scale", 1.0)
1539
+ if scale <= 0:
1540
+ raise ValueError(
1541
+ "Scale parameter for exponential distribution must be positive"
1542
+ )
1543
+ return np.random.exponential(scale, size)
1544
+
1545
+ except Exception as e:
1546
+ raise ValueError(f"Error generating random values: {e}")
1547
+
1548
+ # Create simulated data array
1549
+ if n_volumes == 1:
1550
+ # 3D output
1551
+ simulated_data = np.zeros(original_shape, dtype=np.float32)
1552
+ simulated_data[mask] = _generate_random_values(n_nonzero_voxels)
1553
+ output_shape = original_shape
1554
+
1555
+ else:
1556
+ # 4D output
1557
+ spatial_shape = (
1558
+ original_shape[:3] if len(original_shape) >= 3 else original_shape
1559
+ )
1560
+ output_shape = spatial_shape + (n_volumes,)
1561
+ simulated_data = np.zeros(output_shape, dtype=np.float32)
1562
+
1563
+ # Fill each volume with independent random values
1564
+ for volume_idx in range(n_volumes):
1565
+ simulated_data[..., volume_idx][mask] = _generate_random_values(
1566
+ n_nonzero_voxels
1567
+ )
1568
+
1569
+ # Update header for correct dimensionality
1570
+ header_copy = header.copy()
1571
+
1572
+ if n_volumes == 1 and len(original_shape) > 3:
1573
+ # Convert from 4D+ to 3D
1574
+ header_copy["dim"][0] = 3
1575
+ header_copy["dim"][4] = 1
1576
+
1577
+ elif n_volumes > 1:
1578
+ # Ensure 4D header
1579
+ header_copy["dim"][0] = 4
1580
+ header_copy["dim"][4] = n_volumes
1581
+
1582
+ # Set appropriate time units if not already set
1583
+ if header_copy.get("xyzt_units", 0) == 0:
1584
+ header_copy["xyzt_units"] = 10 # mm + sec
1585
+
1586
+ # Create output image
1587
+ try:
1588
+ simulated_img = nib.Nifti1Image(simulated_data, affine, header_copy)
1589
+ nib.save(simulated_img, simulated_image)
1590
+
1591
+ except Exception as e:
1592
+ raise RuntimeError(f"Failed to create or save simulated image: {e}")
1593
+
1594
+ # Log summary information
1595
+ print(f"Successfully created simulated image:")
1596
+ print(f" Input shape: {original_shape}")
1597
+ print(f" Output shape: {output_shape}")
1598
+ print(f" Non-zero voxels: {n_nonzero_voxels:,}")
1599
+ print(f" Distribution: {distribution}")
1600
+ print(f" Saved to: {simulated_image}")
1601
+
1602
+ return simulated_img
1603
+
1604
+
1605
+ #####################################################################################################
1606
+ def delete_volumes_from_4D_images(
1607
+ in_image: str,
1608
+ out_image: str,
1609
+ vols_to_delete: List[Union[int, tuple, list, str, np.ndarray]] = None,
1610
+ overwrite: bool = False,
1611
+ ) -> Tuple[str, List[int]]:
1612
+ """
1613
+ Remove specific volumes from a 4D neuroimaging file.
1614
+
1615
+ This function removes specified volumes from 4D NIfTI images (such as fMRI time series,
1616
+ DTI volumes, or other 4D datasets) and saves the result to a new file. It supports
1617
+ flexible volume specification including individual indices, ranges, and complex
1618
+ string-based patterns.
1619
+
1620
+ Parameters
1621
+ ----------
1622
+ in_image : str
1623
+ Path to the input 4D NIfTI image file (.nii or .nii.gz).
1624
+ The file must exist and be a valid 4D image.
1625
+
1626
+ out_image : str
1627
+ Path where the output 4D image will be saved. The directory must exist.
1628
+ If the file already exists, use `overwrite=True` to replace it.
1629
+
1630
+ vols_to_delete : list of int, tuple, list, np.ndarray, or str
1631
+ Specification of volumes to remove from the 4D image. Supports multiple formats:
1632
+
1633
+ **Individual integers:**
1634
+ Single volume indices to remove (0-based indexing).
1635
+
1636
+ **Tuples of 2 integers:**
1637
+ Ranges specified as (start, end) - both endpoints are included.
1638
+ Example: (5, 8) removes volumes [5, 6, 7, 8]
1639
+
1640
+ **Lists or numpy arrays:**
1641
+ Collections of volume indices, automatically flattened.
1642
+
1643
+ **Strings (flexible syntax):**
1644
+ Powerful string-based specification supporting:
1645
+
1646
+ - Single numbers: "5" → [5]
1647
+ - Hyphen ranges: "8-10" → [8, 9, 10]
1648
+ - Colon ranges: "11:13" → [11, 12, 13]
1649
+ - Step ranges: "14:2:22" → [14, 16, 18, 20, 22]
1650
+ - Comma-separated: "1, 2, 3" → [1, 2, 3]
1651
+ - Mixed combinations: "0-2, 5, 10:2:14, 20" → [0, 1, 2, 5, 10, 12, 14, 20]
1652
+
1653
+ **Note:** All formats can be mixed in a single list.
1654
+
1655
+ overwrite : bool, default=False
1656
+ Whether to overwrite the output file if it already exists.
1657
+ If False and the output file exists, raises FileExistsError.
1658
+
1659
+ Returns
1660
+ -------
1661
+ out_image : str
1662
+ Path to the successfully created output image file.
1663
+
1664
+ vols_removed : list of int
1665
+ Sorted list of all volume indices that were removed from the original image.
1666
+ Useful for verification and logging purposes.
1667
+
1668
+ Raises
1669
+ ------
1670
+ FileNotFoundError
1671
+ - If the input image file does not exist
1672
+ - If the output directory does not exist
1673
+
1674
+ ValueError
1675
+ - If vols_to_delete is None or empty after parsing
1676
+ - If the input image is not 4D (wrong number of dimensions)
1677
+ - If any volume indices are out of range (negative or >= number of volumes)
1678
+
1679
+ FileExistsError
1680
+ If the output file already exists and overwrite=False.
1681
+
1682
+ RuntimeError
1683
+ If attempting to delete all volumes (would result in empty image).
1684
+
1685
+ Notes
1686
+ -----
1687
+ - Volume indexing is 0-based (first volume is index 0)
1688
+ - Duplicate volume indices are automatically removed
1689
+ - The function preserves the original image's affine transformation and header
1690
+ - Memory usage scales with image size; very large 4D images may require substantial RAM
1691
+ - The function validates all volume indices before processing to prevent partial failures
1692
+
1693
+ **Performance considerations:**
1694
+ - Loading and processing large 4D images may take significant time and memory
1695
+ - Consider processing in chunks for very large datasets
1696
+
1697
+ **File format support:**
1698
+ - Input: .nii and .nii.gz files
1699
+ - Output: Format determined by output filename extension
1700
+
1701
+ Examples
1702
+ --------
1703
+ **Basic usage - Remove specific volumes:**
1704
+
1705
+ >>> delete_volumes_from_4D_images(
1706
+ ... 'fmri_data.nii.gz',
1707
+ ... 'fmri_cleaned.nii.gz',
1708
+ ... vols_to_delete=[0, 1, 2, 99],
1709
+ ... overwrite=True
1710
+ ... )
1711
+
1712
+ **Remove ranges using tuples:**
1713
+
1714
+ >>> delete_volumes_from_4D_images(
1715
+ ... 'dti_data.nii.gz',
1716
+ ... 'dti_subset.nii.gz',
1717
+ ... vols_to_delete=[(0, 4), (95, 99)], # Remove first 5 and last 5 volumes
1718
+ ... overwrite=True
1719
+ ... )
1720
+
1721
+ **String-based range specification:**
1722
+
1723
+ >>> delete_volumes_from_4D_images(
1724
+ ... 'timeseries.nii.gz',
1725
+ ... 'timeseries_clean.nii.gz',
1726
+ ... vols_to_delete=["0-4", "95:99"], # Same as above using strings
1727
+ ... overwrite=True
1728
+ ... )
1729
+
1730
+ **Complex mixed specification:**
1731
+
1732
+ >>> delete_volumes_from_4D_images(
1733
+ ... 'bold_data.nii.gz',
1734
+ ... 'bold_processed.nii.gz',
1735
+ ... vols_to_delete=[
1736
+ ... "0-2", # Remove first 3 volumes (motion artifacts)
1737
+ ... (147, 149), # Remove volumes 147-149 (spike artifacts)
1738
+ ... "200:5:220", # Remove every 5th volume from 200-220
1739
+ ... [300, 301, 302], # Remove specific outlier volumes
1740
+ ... "450" # Remove final volume
1741
+ ... ],
1742
+ ... overwrite=True
1743
+ ... )
1744
+
1745
+ **Advanced string patterns:**
1746
+
1747
+ >>> # Remove multiple ranges and individual volumes in one string
1748
+ >>> delete_volumes_from_4D_images(
1749
+ ... 'input.nii.gz',
1750
+ ... 'output.nii.gz',
1751
+ ... vols_to_delete=["0-2, 5, 10:2:14, 20-22, 50"],
1752
+ ... overwrite=True
1753
+ ... )
1754
+ >>> # This removes: [0,1,2,5,10,12,14,20,21,22,50]
1755
+
1756
+ """
1757
+
1758
+ # Check if input file exists
1759
+ if not os.path.isfile(in_image):
1760
+ raise FileNotFoundError(f"File {in_image} not found.")
1761
+
1762
+ # Check if volumes to delete is specified
1763
+ if vols_to_delete is None:
1764
+ raise ValueError(
1765
+ "vols_to_delete parameter is required. Please specify which volumes to remove."
1766
+ )
1767
+
1768
+ # Ensure vols_to_delete is a list
1769
+ if not isinstance(vols_to_delete, list):
1770
+ vols_to_delete = [vols_to_delete]
1771
+
1772
+ # Convert vols_to_delete to a flat list of integers
1773
+ vols_to_delete = cltmisc.build_indices(vols_to_delete, nonzeros=False)
1774
+
1775
+ # Check if vols_to_delete is not empty
1776
+ if len(vols_to_delete) == 0:
1777
+ print("No volumes to delete. The volumes to delete list is empty.")
1778
+ return in_image, []
1779
+
1780
+ # Create output filename if not specified
1781
+ out_path = os.path.dirname(out_image)
1782
+
1783
+ # Check if the output path exists otherwise give an error
1784
+ if out_path and not os.path.exists(out_path):
1785
+ raise FileNotFoundError(f"Output directory {out_path} does not exist.")
1786
+
1787
+ if os.path.exists(out_image) and not overwrite:
1788
+ raise FileExistsError(
1789
+ f"Output file {out_image} already exists. Use 'overwrite=True' to replace it."
1790
+ )
1791
+
1792
+ # Load the 4D image
1793
+ img = nib.load(in_image)
1794
+
1795
+ # Get the dimensions of the image
1796
+ dim = img.shape
1797
+
1798
+ # Check if the image is 4D
1799
+ if len(dim) != 4:
1800
+ raise ValueError(
1801
+ f"Image {in_image} is not a 4D image. It has {len(dim)} dimensions."
1802
+ )
1803
+
1804
+ # Get the number of volumes
1805
+ nvols = dim[3]
1806
+
1807
+ # Check if trying to delete all volumes
1808
+ if len(vols_to_delete) == nvols:
1809
+ print(
1810
+ "Number of volumes to delete is equal to the total number of volumes. No volumes will be deleted."
1811
+ )
1812
+ return in_image, []
1813
+
1814
+ # Check if volumes to delete are in valid range
1815
+ if np.max(vols_to_delete) >= nvols:
1816
+ vols_to_delete_array = np.array(vols_to_delete)
1817
+ out_of_range = np.where(vols_to_delete_array >= nvols)[0]
1818
+ raise ValueError(
1819
+ f"Volumes out of range: {vols_to_delete_array[out_of_range]}. "
1820
+ f"Values should be between 0 and {nvols-1}."
1821
+ )
1822
+
1823
+ if np.min(vols_to_delete) < 0:
1824
+ raise ValueError(
1825
+ f"Volumes to delete {vols_to_delete} contain negative indices. "
1826
+ f"Values should be between 0 and {nvols-1}."
1827
+ )
1828
+
1829
+ # Get volumes to keep and remove
1830
+ vols_to_remove = np.array(vols_to_delete)
1831
+ vols_to_keep = np.where(np.isin(np.arange(nvols), vols_to_remove, invert=True))[0]
1832
+
1833
+ # Load image data
1834
+ img_data = img.get_fdata()
1835
+ affine = img.affine
1836
+ header = img.header
1837
+
1838
+ # Remove the specified volumes
1839
+ filtered_data = img_data[:, :, :, vols_to_keep]
1840
+
1841
+ # Create new image with filtered data
1842
+ new_img = nib.Nifti1Image(filtered_data, affine, header)
1843
+
1844
+ # Save the new image
1845
+ nib.save(new_img, out_image)
1846
+
1847
+ print(f"Successfully removed {len(vols_to_remove)} volumes from {in_image}")
1848
+ print(f"Output saved to: {out_image}")
1849
+ print(f"Volumes removed: {vols_to_remove.tolist()}")
1850
+
1851
+ return out_image, vols_to_remove.tolist()
1852
+
1853
+
1854
+ ####################################################################################################
1855
+ ####################################################################################################
1856
+ ############ ############
1857
+ ############ ############
1858
+ ############ Section 6: Methods to perform operations from 3D or 4D arrays ############
1859
+ ############ ############
1860
+ ############ ############
1861
+ ####################################################################################################
1862
+ ####################################################################################################
1863
+ def extract_mesh_from_volume(
1864
+ volume_array: np.ndarray,
1865
+ gaussian_smooth: bool = True,
1866
+ sigma: float = 1.0,
1867
+ fill_holes: bool = True,
1868
+ smooth_iterations: int = 10,
1869
+ affine: np.ndarray = None,
1870
+ closing_iterations: int = 1,
1871
+ vertex_value: np.float32 = 1.0,
1872
+ ) -> pv.PolyData:
1873
+ """
1874
+ Extract surface mesh from 3D volume using marching cubes algorithm.
1875
+
1876
+ Creates high-quality surface mesh with optional smoothing, hole filling,
1877
+ and coordinate transformation to millimeter space.
1878
+
1879
+ Parameters
1880
+ ----------
1881
+ volume_array : np.ndarray
1882
+ 3D binary volume array for mesh extraction.
1883
+
1884
+ gaussian_smooth : bool, optional
1885
+ Whether to apply Gaussian smoothing before extraction. Default is True.
1886
+
1887
+ sigma : float, optional
1888
+ Standard deviation for Gaussian smoothing. Default is 1.0.
1889
+
1890
+ fill_holes : bool, optional
1891
+ Whether to fill holes in extracted mesh. Default is True.
1892
+
1893
+ smooth_iterations : int, optional
1894
+ Number of Taubin smoothing iterations. Default is 10.
1895
+
1896
+ affine : np.ndarray, optional
1897
+ 4x4 affine matrix to transform vertices to mm space. Default is None.
1898
+
1899
+ closing_iterations : int, optional
1900
+ Morphological closing iterations before extraction. Default is 1.
1901
+
1902
+ vertex_value : float, optional
1903
+ Scalar value assigned to mesh vertices. Default is 1.0.
1904
+
1905
+ Returns
1906
+ -------
1907
+ pv.PolyData
1908
+ PyVista mesh with vertices in mm coordinates (if affine provided),
1909
+ computed normals, and scalar values.
1910
+
1911
+ Raises
1912
+ ------
1913
+ TypeError
1914
+ If volume_array is not a numpy array.
1915
+
1916
+ ValueError
1917
+ If volume_array is not 3D or no surface can be extracted.
1918
+
1919
+ Notes
1920
+ -----
1921
+ The extraction pipeline includes:
1922
+ 1. Morphological closing to fill small gaps
1923
+ 2. Optional Gaussian smoothing for noise reduction
1924
+ 3. Marching cubes surface extraction
1925
+ 4. Mesh cleaning and hole filling
1926
+ 5. Taubin smoothing for feature preservation
1927
+ 6. Normal computation for proper shading
1928
+
1929
+ Examples
1930
+ --------
1931
+ >>> # Basic mesh extraction
1932
+ >>> mesh = extract_mesh_from_volume(binary_volume)
1933
+ >>> print(f"Mesh has {mesh.n_points} vertices and {mesh.n_cells} faces")
1934
+ >>>
1935
+ >>> # High-quality mesh with coordinate transformation
1936
+ >>> mesh = extract_mesh_from_volume(
1937
+ ... binary_volume,
1938
+ ... affine=img.affine,
1939
+ ... smooth_iterations=20,
1940
+ ... fill_holes=True
1941
+ ... )
1942
+ >>>
1943
+ >>> # Save mesh
1944
+ >>> mesh.save('surface.ply')
1945
+ """
1946
+
1947
+ # Binary mask for the specified value
1948
+ if not isinstance(volume_array, np.ndarray):
1949
+ raise TypeError("The volume_array must be a numpy ndarray.")
1950
+
1951
+ if volume_array.ndim != 3:
1952
+ raise ValueError("The volume_array must be a 3D numpy ndarray.")
1953
+
1954
+ # Everything that is different from 0 is set to 1
1955
+ volume_array = (volume_array != 0).astype(np.float32)
1956
+
1957
+ if closing_iterations > 0:
1958
+ volume_array = quick_morphology(
1959
+ volume_array, "closing", iterations=closing_iterations
1960
+ )
1961
+
1962
+ # Apply Gaussian smoothing to reduce noise and fill small gaps
1963
+ if gaussian_smooth:
1964
+
1965
+ # Apply Gaussian smoothing
1966
+ tmp_volume_array = gaussian_filter(volume_array, sigma=sigma)
1967
+ # Re-threshold after smoothing
1968
+ tmp_volume_array = (tmp_volume_array > 0).astype(int)
1969
+
1970
+ if tmp_volume_array.max() == 0:
1971
+ tmp_volume_array = copy.deepcopy(volume_array)
1972
+ else:
1973
+ tmp_volume_array = copy.deepcopy(volume_array)
1974
+
1975
+ # Check if the code exists in the data
1976
+ # Extract surface using marching cubes
1977
+ vertices, faces, normals, values = measure.marching_cubes(
1978
+ volume_array, level=0.5, gradient_direction="ascent"
1979
+ )
1980
+ if len(faces) == 0:
1981
+ raise ValueError(
1982
+ f"No surface extracted for value. The volume may not contain sufficient data."
1983
+ )
1984
+
1985
+ # Move vertices to mm space and the apply affine transformation if the affine is provided
1986
+ # and it is a 4x4 numpy array
1987
+ # If the affine is not provided, the vertices will remain in voxel space
1988
+ # Convert vertices to mm space
1989
+ if affine is not None and isinstance(affine, np.ndarray) and affine.shape == (4, 4):
1990
+ vertices = vox2mm(vertices, affine=affine)
1991
+
1992
+ # Add column with 3's to faces array for PyVista
1993
+ faces = np.c_[np.full(len(faces), 3), faces]
1994
+
1995
+ mesh = pv.PolyData(vertices, faces)
1996
+
1997
+ # Mesh processing pipeline for better quality
1998
+ # 1. Clean the mesh (remove duplicate points, unused points, degenerate cells)
1999
+ mesh = mesh.clean()
2000
+
2001
+ # 2. Fill holes if requested
2002
+ if fill_holes:
2003
+ mesh = mesh.fill_holes(1000) # Fill holes with max 1000 triangles
2004
+
2005
+ # 3. Apply Taubin smoothing (preserves features better than Laplacian)
2006
+ if smooth_iterations > 0:
2007
+ mesh = mesh.smooth_taubin(n_iter=smooth_iterations, pass_band=0.1)
2008
+
2009
+ # 4. Clean again after smoothing
2010
+ mesh = mesh.clean()
2011
+
2012
+ # 5. Compute normals for better shading
2013
+ mesh = mesh.compute_normals(split_vertices=True)
2014
+
2015
+ mesh.point_data["surface"] = (
2016
+ np.ones((len(mesh.points), 1), dtype=np.uint32) * vertex_value
2017
+ )
2018
+
2019
+ return mesh
2020
+
2021
+
2022
+ #####################################################################################################
2023
+ def extract_centroid_from_volume(
2024
+ volume_array: np.ndarray,
2025
+ gaussian_smooth: bool = True,
2026
+ sigma: float = 1.0,
2027
+ closing_iterations: int = 1,
2028
+ ) -> tuple:
2029
+ """
2030
+ Extract centroid and voxel count from a 3D binary volume.
2031
+ Computes the centroid of the non-zero region in the volume and counts the number of voxels.
2032
+ Optionally applies Gaussian smoothing and morphological closing to improve the region definition.
2033
+
2034
+ Parameters
2035
+ ----------
2036
+ volume_array : np.ndarray
2037
+ 3D binary volume array where non-zero values represent the region of interest.
2038
+
2039
+ gaussian_smooth : bool, optional
2040
+ Whether to apply Gaussian smoothing before centroid calculation. Default is True.
2041
+
2042
+ sigma : float, optional
2043
+ Standard deviation for Gaussian smoothing. Default is 1.0.
2044
+
2045
+ closing_iterations : int, optional
2046
+ Number of morphological closing iterations to apply before centroid calculation. Default is 1.
2047
+
2048
+ Returns
2049
+ -------
2050
+ tuple
2051
+ A tuple containing:
2052
+ - Centroid coordinates as a numpy array of shape (3,) in mm space.
2053
+ - Voxel count as an integer.
2054
+
2055
+ Raises
2056
+ ------
2057
+ TypeError
2058
+ If volume_array is not a numpy ndarray.
2059
+
2060
+ ValueError
2061
+ If volume_array is not 3D or if no region is found in the volume.
2062
+
2063
+ ValueError
2064
+ If the volume does not contain sufficient data to compute a centroid.
2065
+
2066
+
2067
+ Notes
2068
+ The function processes the input volume as follows:
2069
+ 1. Converts non-zero values to 1 to create a binary mask.
2070
+ 2. Applies morphological closing to fill small gaps in the region.
2071
+ 3. Optionally applies Gaussian smoothing to reduce noise.
2072
+ 4. Computes the centroid of the non-zero region.
2073
+ 5. Counts the number of voxels in the region.
2074
+ 6. Returns the centroid coordinates and voxel count as a numpy array.
2075
+
2076
+ Examples
2077
+ --------
2078
+ >>> # Basic centroid extraction
2079
+ >>> centroid_info = extract_centroid_from_volume(binary_volume)
2080
+ >>> print(f"Centroid: {centroid_info[:3]}, Voxel Count: {centroid_info[3]}")
2081
+ >>>
2082
+ >>> # With Gaussian smoothing and morphological closing
2083
+ >>> centroid_info = extract_centroid_from_volume(binary_volume, gaussian_smooth=True, sigma=1.5, closing_iterations=2)
2084
+ >>> print(f"Centroid: {centroid_info[:3]}, Voxel Count: {centroid_info[3]}")
2085
+ """
2086
+
2087
+ # Binary mask for the specified value
2088
+ if not isinstance(volume_array, np.ndarray):
2089
+ raise TypeError("The volume_array must be a numpy ndarray.")
2090
+
2091
+ if volume_array.ndim != 3:
2092
+ raise ValueError("The volume_array must be a 3D numpy ndarray.")
2093
+
2094
+ # Everything that is different from 0 is set to 1
2095
+ volume_array = (volume_array != 0).astype(np.float32)
2096
+
2097
+ if closing_iterations > 0:
2098
+ volume_array = quick_morphology(
2099
+ volume_array, "closing", iterations=closing_iterations
2100
+ )
2101
+
2102
+ # Apply Gaussian smoothing to reduce noise and fill small gaps
2103
+ if gaussian_smooth:
2104
+
2105
+ # Apply Gaussian smoothing
2106
+ tmp_volume_array = gaussian_filter(volume_array, sigma=sigma)
2107
+ # Re-threshold after smoothing
2108
+ tmp_volume_array = (tmp_volume_array > 0).astype(int)
2109
+
2110
+ if tmp_volume_array.max() == 0:
2111
+ tmp_volume_array = copy.deepcopy(volume_array)
2112
+ else:
2113
+ tmp_volume_array = copy.deepcopy(volume_array)
2114
+
2115
+ # Create mask for current region
2116
+ region_x, region_y, region_z = np.where(tmp_volume_array != 0)
2117
+
2118
+ # Skip if region doesn't exist in the data
2119
+ if len(region_x) == 0 and len(region_y) == 0 and len(region_z) == 0:
2120
+ return (np.array([None, None, None], dtype=np.float32), 0)
2121
+
2122
+ else:
2123
+ # Compute centroid
2124
+ centroid_x = np.mean(region_x)
2125
+ centroid_y = np.mean(region_y)
2126
+ centroid_z = np.mean(region_z)
2127
+
2128
+ # Count voxels and compute volume
2129
+ voxel_count = len(region_x)
2130
+
2131
+ return (
2132
+ np.array([centroid_x, centroid_y, centroid_z], dtype=np.float32),
2133
+ int(voxel_count),
2134
+ )
2135
+
2136
+
2137
+ ####################################################################################################
2138
+ def compute_statistics_at_nonzero_voxels(
2139
+ mask_array: np.ndarray, data_array: np.ndarray, metric: str = "mean"
2140
+ ) -> np.ndarray:
2141
+ """
2142
+ Compute the value of certain statistic from data_array at positions where mask_array is non-zero.
2143
+
2144
+ Parameters
2145
+ -----------
2146
+ mask_array : numpy.ndarray
2147
+ 3D array with dimensions (N, M, P) - used to find non-zero positions
2148
+ Non-zero positions in this array indicate where to compute the statistic in data_array.
2149
+
2150
+ data_array : numpy.ndarray
2151
+ 3D array (N, M, P) or 4D array (N, M, P, T) - data to compute mean from
2152
+ If 3D, computes a single mean value.
2153
+ If 4D, computes mean across non-zero positions for each time point T.
2154
+
2155
+ metric : str, optional
2156
+ Metric to compute at non-zero positions. Default is "mean".
2157
+ Supported metrics: "mean", "std", "var", "median", "sum", "max", "min".
2158
+
2159
+ Returns
2160
+ --------
2161
+ numpy.ndarray or float
2162
+ - If data_array is 3D: returns a single float value
2163
+ - If data_array is 4D: returns a 1D array of length T
2164
+
2165
+ Raises
2166
+ -------
2167
+ ValueError
2168
+ If data_array is not 3D or 4D.
2169
+
2170
+ ValueError
2171
+ If metric is not one of the supported metrics.
2172
+
2173
+ Notes
2174
+ -----
2175
+ - The function extracts values from data_array at positions where mask_array is non-zero.
2176
+ - For 3D data_array, it computes the mean of all selected values.
2177
+ - For 4D data_array, it computes the mean across non-zero positions for each time point,
2178
+ returning a 1D array with the mean for each time point.
2179
+ - If mask_array has no non-zero positions, the function will return NaN for 3D data_array
2180
+ or an array of NaNs for 4D data_array.
2181
+
2182
+ Examples
2183
+ ---------
2184
+ >>> # Example with 3D data_array
2185
+ >>> mask = np.array([[[0, 1], [0, 0]],
2186
+ ... [[1, 0], [0, 1]]])
2187
+ >>> data = np.array([[[1, 2], [3, 4]],
2188
+ ... [[5, 6], [7, 8]]])
2189
+ >>> mean_value = compute_statistics_at_nonzero_voxels(mask, data)
2190
+ >>> print(mean_value) # Output: 4.5 (mean of 2, 5, 6, 8)
2191
+ >>>
2192
+ >>> # Example with 4D data_array
2193
+ >>> mask_4d = np.array([[[0, 1], [0, 0]],
2194
+ ... [[1, 0], [0, 1]]])
2195
+ >>> data_4d = np.array([[[[1, 2], [3, 4]],
2196
+ ... [[5, 6], [7, 8]]],
2197
+ ... [[[9, 10], [11, 12]],
2198
+ ... [[13, 14], [15, 16]]]])
2199
+ >>> mean_values_4d = compute_statistics_at_nonzero_voxels(mask_4d, data_4d)
2200
+ >>> print(mean_values_4d) # Output: [ 6. 8. 10. 12.] (mean for each time point
2201
+ 1, 2, 3, 4)
2202
+
2203
+ """
2204
+
2205
+ # Validate metric
2206
+ metric = metric.lower()
2207
+ valid_metrics = ["mean", "std", "var", "median", "sum", "max", "min"]
2208
+ if metric not in valid_metrics:
2209
+ raise ValueError(
2210
+ f"Invalid metric '{metric}'. Supported metrics: {', '.join(valid_metrics)}"
2211
+ )
2212
+
2213
+ # Find indices where mask_array is non-zero
2214
+ nonzero_mask = mask_array != 0
2215
+
2216
+ # Check if data_array is 3D or 4D
2217
+ if data_array.ndim == 3:
2218
+ # 3D case: extract values at non-zero positions and compute mean
2219
+ selected_values = data_array[nonzero_mask]
2220
+ if len(selected_values) == 0:
2221
+ return np.nan
2222
+
2223
+ if metric == "mean":
2224
+ return np.mean(selected_values)
2225
+
2226
+ elif metric == "std":
2227
+ return np.std(selected_values)
2228
+
2229
+ elif metric == "var":
2230
+ return np.var(selected_values)
2231
+
2232
+ elif metric == "median":
2233
+ return np.median(selected_values)
2234
+
2235
+ elif metric == "sum":
2236
+ return np.sum(selected_values)
2237
+
2238
+ elif metric == "max":
2239
+ return np.max(selected_values)
2240
+
2241
+ elif metric == "min":
2242
+ return np.min(selected_values)
2243
+
2244
+ elif data_array.ndim == 4:
2245
+ # 4D case: extract values at non-zero positions for each time point
2246
+ selected_values = data_array[nonzero_mask, :] # Shape: (num_nonzero_voxels, T)
2247
+
2248
+ if metric == "mean":
2249
+ return np.mean(selected_values, axis=0) # Mean across voxels, returns (T,)
2250
+ elif metric == "std":
2251
+ return np.std(selected_values, axis=0) # Std across voxels, returns (T,)
2252
+ elif metric == "var":
2253
+ return np.var(
2254
+ selected_values, axis=0
2255
+ ) # Variance across voxels, returns (T,)
2256
+ elif metric == "median":
2257
+ return np.median(
2258
+ selected_values, axis=0
2259
+ ) # Median across voxels, returns (T,)
2260
+ elif metric == "sum":
2261
+ return np.sum(selected_values, axis=0) # Sum across voxels, returns (T,)
2262
+ elif metric == "max":
2263
+ return np.max(selected_values, axis=0) # Max across voxels, returns (T,)
2264
+ elif metric == "min":
2265
+ return np.min(selected_values, axis=0) # Min across voxels, returns (T,)
2266
+
2267
+ else:
2268
+ raise ValueError("data_array must be 3D or 4D")
2269
+
2270
+
2271
+ #####################################################################################################
2272
+ def region_growing(
2273
+ iparc: np.ndarray, mask: Union[np.ndarray, np.bool_], neighborhood="26"
2274
+ ) -> np.ndarray:
2275
+ """
2276
+ Fill gaps in parcellation using region growing algorithm.
2277
+
2278
+ Labels unlabeled voxels within the mask by assigning the most frequent
2279
+ label among their labeled neighbors, iteratively until convergence or
2280
+ no more voxels can be labeled.
2281
+
2282
+ Parameters
2283
+ ----------
2284
+ iparc : np.ndarray
2285
+ 3D parcellation array with labeled (>0) and unlabeled (0) voxels.
2286
+
2287
+ mask : np.ndarray or np.bool_
2288
+ 3D binary mask defining the region where growing should occur.
2289
+
2290
+ neighborhood : str, optional
2291
+ Neighborhood connectivity: '6', '18', or '26' for 3D. Default is '26'.
2292
+
2293
+ Returns
2294
+ -------
2295
+ np.ndarray
2296
+ Updated parcellation array with gaps filled, masked to input mask.
2297
+
2298
+ Notes
2299
+ -----
2300
+ The algorithm works iteratively:
2301
+ 1. Identifies unlabeled voxels with at least one labeled neighbor
2302
+ 2. For each candidate voxel, finds most frequent label among neighbors
2303
+ 3. In case of ties, selects label from spatially closest neighbor
2304
+ 4. Repeats until no more voxels can be labeled or convergence
2305
+
2306
+ Particularly useful for:
2307
+ - Filling gaps in atlas-based parcellations
2308
+ - Completing partial segmentations
2309
+ - Correcting registration artifacts
2310
+
2311
+ Examples
2312
+ --------
2313
+ >>> # Fill gaps in parcellation
2314
+ >>> filled_parc = region_growing(parcellation_array, brain_mask)
2315
+ >>> print(f"Filled {np.sum(filled_parc > 0) - np.sum(parcellation_array > 0)} voxels")
2316
+ >>>
2317
+ >>> # Use 6-connectivity for more conservative growing
2318
+ >>> conservative_fill = region_growing(
2319
+ ... incomplete_labels,
2320
+ ... region_mask,
2321
+ ... neighborhood='6'
2322
+ ... )
2323
+ """
2324
+
2325
+ # Create a binary array where labeled voxels are marked as 1
2326
+ binary_labels = (iparc > 0).astype(int)
2327
+
2328
+ # Convolve with the kernel to count labeled neighbors for each voxel
2329
+ kernel = np.array(
2330
+ [
2331
+ [[1, 1, 1], [1, 1, 1], [1, 1, 1]],
2332
+ [[1, 1, 1], [1, 0, 1], [1, 1, 1]],
2333
+ [[1, 1, 1], [1, 1, 1], [1, 1, 1]],
2334
+ ]
2335
+ )
2336
+ labeled_neighbor_count = convolve(binary_labels, kernel, mode="constant", cval=0)
2337
+
2338
+ # Mask for voxels that have at least one labeled neighbor
2339
+ mask_with_labeled_neighbors = (labeled_neighbor_count > 0) & (iparc == 0)
2340
+ ind = np.argwhere(
2341
+ (mask_with_labeled_neighbors != 0) & (binary_labels == 0) & (mask)
2342
+ )
2343
+ ind_orig = ind.copy() * 0
2344
+
2345
+ # Loop until no more voxels could be labeled or all the voxels are labeled
2346
+ while (len(ind) > 0) & (np.array_equal(ind, ind_orig) == False):
2347
+ ind_orig = ind.copy()
2348
+ # Process each unlabeled voxel
2349
+ for coord in ind:
2350
+ x, y, z = coord
2351
+
2352
+ # Detecting the neighbors
2353
+ neighbors = get_vox_neighbors(coord=coord, neighborhood="26", dims="3")
2354
+ # Remove from motion the coordinates out of the bounding box
2355
+ neighbors = neighbors[
2356
+ (neighbors[:, 0] >= 0)
2357
+ & (neighbors[:, 0] < iparc.shape[0])
2358
+ & (neighbors[:, 1] >= 0)
2359
+ & (neighbors[:, 1] < iparc.shape[1])
2360
+ & (neighbors[:, 2] >= 0)
2361
+ & (neighbors[:, 2] < iparc.shape[2])
2362
+ ]
2363
+
2364
+ # Labels of the neighbors
2365
+ neigh_lab = iparc[neighbors[:, 0], neighbors[:, 1], neighbors[:, 2]]
2366
+
2367
+ if len(np.argwhere(neigh_lab > 0)) > 2:
2368
+
2369
+ # Remove the neighbors that are not labeled
2370
+ neighbors = neighbors[neigh_lab > 0]
2371
+ neigh_lab = neigh_lab[neigh_lab > 0]
2372
+
2373
+ unique_labels, counts = np.unique(neigh_lab, return_counts=True)
2374
+ max_count = counts.max()
2375
+ max_labels = unique_labels[counts == max_count]
2376
+
2377
+ if len(max_labels) == 1:
2378
+ iparc[x, y, z] = max_labels[0]
2379
+
2380
+ else:
2381
+ # In case of tie, choose the label of the closest neighbor
2382
+ distances = [
2383
+ distance.euclidean(coord, (dx, dy, dz))
2384
+ for (dx, dy, dz), lbl in zip(neighbors, neigh_lab)
2385
+ if lbl in max_labels
2386
+ ]
2387
+ closest_label = max_labels[np.argmin(distances)]
2388
+ iparc[x, y, z] = closest_label
2389
+ # most_frequent_label = np.bincount(neigh_lab[neigh_lab != 0]).argmax()
2390
+
2391
+ # Create a binary array where labeled voxels are marked as 1
2392
+ binary_labels = (iparc > 0).astype(int)
2393
+
2394
+ # Convolve with the kernel to count labeled neighbors for each voxel
2395
+ labeled_neighbor_count = convolve(
2396
+ binary_labels, kernel, mode="constant", cval=0
2397
+ )
2398
+
2399
+ # Mask for voxels that have at least one labeled neighbor
2400
+ mask_with_labeled_neighbors = (labeled_neighbor_count > 0) & (iparc == 0)
2401
+ ind = np.argwhere(
2402
+ (mask_with_labeled_neighbors != 0) & (binary_labels == 0) & (mask)
2403
+ )
2404
+
2405
+ return iparc * mask
2406
+
2407
+
2408
+ #####################################################################################################
2409
+ def simulate_array(
2410
+ mask_array: np.ndarray,
2411
+ n_volumes: int = 1,
2412
+ distribution: str = "normal",
2413
+ random_seed: Optional[int] = None,
2414
+ **dist_params,
2415
+ ) -> np.ndarray:
2416
+ """
2417
+ Generate a simulated array with random values at non-zero voxel positions.
2418
+
2419
+ This function creates a new array where non-zero voxels from the mask array
2420
+ are filled with random values following a specified statistical distribution.
2421
+
2422
+ Parameters
2423
+ ----------
2424
+ mask_array : numpy.ndarray
2425
+ 3D array used as a mask to determine where to place random values.
2426
+ Shape: (N, M, P)
2427
+
2428
+ n_volumes : int, default=1
2429
+ Number of volumes in the output array:
2430
+ - If n_volumes == 1: creates a 3D array (N, M, P)
2431
+ - If n_volumes > 1: creates a 4D array (N, M, P, n_volumes)
2432
+
2433
+ distribution : str, default='normal'
2434
+ Statistical distribution for random value generation. Supported options:
2435
+ - 'normal': Normal (Gaussian) distribution
2436
+ - 'uniform': Uniform distribution
2437
+ - 'exponential': Exponential distribution
2438
+
2439
+ random_seed : int, optional
2440
+ Random seed for reproducible results. If None, uses system time.
2441
+
2442
+ **dist_params : dict
2443
+ Distribution-specific parameters:
2444
+ - For 'normal': loc (mean, default=0), scale (std, default=1)
2445
+ - For 'uniform': low (default=0), high (default=1)
2446
+ - For 'exponential': scale (default=1)
2447
+
2448
+ Returns
2449
+ -------
2450
+ numpy.ndarray
2451
+ Simulated array with random values at non-zero mask positions:
2452
+ - 3D array (N, M, P) if n_volumes == 1
2453
+ - 4D array (N, M, P, n_volumes) if n_volumes > 1
2454
+
2455
+ Raises
2456
+ ------
2457
+ ValueError
2458
+ If mask_array is not 3D, distribution is unsupported,
2459
+ n_volumes is invalid, or distribution parameters are invalid.
2460
+
2461
+ RuntimeError
2462
+ If no non-zero voxels are found in the mask array.
2463
+
2464
+ Examples
2465
+ --------
2466
+ >>> # Create 3D simulation with normal distribution
2467
+ >>> mask = np.random.randint(0, 2, (64, 64, 30))
2468
+ >>> sim_3d = simulate_array(
2469
+ ... mask,
2470
+ ... n_volumes=1,
2471
+ ... distribution='normal',
2472
+ ... loc=0,
2473
+ ... scale=1,
2474
+ ... random_seed=42
2475
+ ... )
2476
+
2477
+ >>> # Create 4D simulation with uniform distribution
2478
+ >>> sim_4d = simulate_array(
2479
+ ... mask,
2480
+ ... n_volumes=10,
2481
+ ... distribution='uniform',
2482
+ ... low=0,
2483
+ ... high=100,
2484
+ ... random_seed=42
2485
+ ... )
2486
+
2487
+ Notes
2488
+ -----
2489
+ - Only voxels with non-zero values in the mask array will contain random values
2490
+ - All other voxels remain zero in the output
2491
+ - For 4D outputs, each volume gets independent random values
2492
+ """
2493
+
2494
+ # Input validation
2495
+ if not isinstance(mask_array, np.ndarray):
2496
+ raise ValueError("mask_array must be a numpy array")
2497
+
2498
+ if mask_array.ndim != 3:
2499
+ raise ValueError(f"mask_array must be 3D, got {mask_array.ndim}D")
2500
+
2501
+ if not isinstance(n_volumes, int) or n_volumes < 1:
2502
+ raise ValueError("n_volumes must be a positive integer")
2503
+
2504
+ if distribution not in ["normal", "uniform", "exponential"]:
2505
+ raise ValueError(
2506
+ f"Unsupported distribution '{distribution}'. "
2507
+ "Supported: 'normal', 'uniform', 'exponential'"
2508
+ )
2509
+
2510
+ # Set random seed if provided
2511
+ if random_seed is not None:
2512
+ np.random.seed(random_seed)
2513
+
2514
+ # Create mask for non-zero voxels
2515
+ mask = mask_array != 0
2516
+ n_nonzero_voxels = np.sum(mask)
2517
+
2518
+ if n_nonzero_voxels == 0:
2519
+ raise RuntimeError("No non-zero voxels found in mask array")
2520
+
2521
+ # Distribution parameter validation and random value generation
2522
+ def _generate_random_values(size: int) -> np.ndarray:
2523
+ """Generate random values based on specified distribution."""
2524
+ try:
2525
+ if distribution == "normal":
2526
+ loc = dist_params.get("loc", 0.0)
2527
+ scale = dist_params.get("scale", 1.0)
2528
+ if scale <= 0:
2529
+ raise ValueError(
2530
+ "Scale parameter for normal distribution must be positive"
2531
+ )
2532
+ return np.random.normal(loc, scale, size).astype(np.float32)
2533
+
2534
+ elif distribution == "uniform":
2535
+ low = dist_params.get("low", 0.0)
2536
+ high = dist_params.get("high", 1.0)
2537
+ if low >= high:
2538
+ raise ValueError(
2539
+ "Low parameter must be less than high parameter for uniform distribution"
2540
+ )
2541
+ return np.random.uniform(low, high, size).astype(np.float32)
2542
+
2543
+ elif distribution == "exponential":
2544
+ scale = dist_params.get("scale", 1.0)
2545
+ if scale <= 0:
2546
+ raise ValueError(
2547
+ "Scale parameter for exponential distribution must be positive"
2548
+ )
2549
+ return np.random.exponential(scale, size).astype(np.float32)
2550
+
2551
+ except Exception as e:
2552
+ raise ValueError(f"Error generating random values: {e}")
2553
+
2554
+ # Get mask shape
2555
+ mask_shape = mask_array.shape
2556
+
2557
+ # Create simulated data array
2558
+ if n_volumes == 1:
2559
+ # 3D output
2560
+ simulated_data = np.zeros(mask_shape, dtype=np.float32)
2561
+ simulated_data[mask] = _generate_random_values(n_nonzero_voxels)
2562
+ output_shape = mask_shape
2563
+
2564
+ else:
2565
+ # 4D output
2566
+ output_shape = mask_shape + (n_volumes,)
2567
+ simulated_data = np.zeros(output_shape, dtype=np.float32)
2568
+
2569
+ # Fill each volume with independent random values
2570
+ for volume_idx in range(n_volumes):
2571
+ simulated_data[..., volume_idx][mask] = _generate_random_values(
2572
+ n_nonzero_voxels
2573
+ )
2574
+
2575
+ return simulated_data
2576
+
2577
+
2578
+ #####################################################################################################
2579
+ def delete_volumes_from_4D_array(
2580
+ in_array: np.ndarray,
2581
+ vols_to_delete: List[Union[int, tuple, list, str, np.ndarray]] = None,
2582
+ ) -> Tuple[str, List[int]]:
2583
+ """
2584
+ Remove specific volumes from a 4D array.
2585
+
2586
+ This function allows you to delete specified volumes from a 4D numpy array,
2587
+ which is commonly used in neuroimaging data (e.g., fMRI, DTI). It supports
2588
+ various formats for specifying which volumes to remove, including individual indices,
2589
+ ranges, lists, and flexible string-based specifications.
2590
+ The function returns the modified array and a list of removed volume indices.
2591
+ It is designed to handle large 4D arrays efficiently and ensures that the
2592
+ integrity of the remaining data is preserved.
2593
+ 4D arrays are expected to have the shape (X, Y, Z, T), where T is the number of volumes.
2594
+
2595
+ Parameters
2596
+ ----------
2597
+ in_array : np.ndarray
2598
+ Input 4D numpy array from which volumes will be removed.
2599
+
2600
+ vols_to_delete : list of int, tuple, list, np.ndarray, or str
2601
+ Specification of volumes to remove from the 4D image. Supports multiple formats:
2602
+
2603
+ **Individual integers:**
2604
+ Single volume indices to remove (0-based indexing).
2605
+
2606
+ **Tuples of 2 integers:**
2607
+ Ranges specified as (start, end) - both endpoints are included.
2608
+ Example: (5, 8) removes volumes [5, 6, 7, 8]
2609
+
2610
+ **Lists or numpy arrays:**
2611
+ Collections of volume indices, automatically flattened.
2612
+
2613
+ **Strings (flexible syntax):**
2614
+ Powerful string-based specification supporting:
2615
+
2616
+ - Single numbers: "5" → [5]
2617
+ - Hyphen ranges: "8-10" → [8, 9, 10]
2618
+ - Colon ranges: "11:13" → [11, 12, 13]
2619
+ - Step ranges: "14:2:22" → [14, 16, 18, 20, 22]
2620
+ - Comma-separated: "1, 2, 3" → [1, 2, 3]
2621
+ - Mixed combinations: "0-2, 5, 10:2:14, 20" → [0, 1, 2, 5, 10, 12, 14, 20]
2622
+
2623
+ **Note:** All formats can be mixed in a single list.
2624
+
2625
+ Returns
2626
+ -------
2627
+ out_array : np.ndarray
2628
+ The modified 4D numpy array with specified volumes removed.
2629
+
2630
+ vols_removed : list of int
2631
+ Sorted list of all volume indices that were removed from the original image.
2632
+ Useful for verification and logging purposes.
2633
+
2634
+ Raises
2635
+ ------
2636
+ TypeError
2637
+ If `in_array` is not a numpy ndarray or if `vols_to_delete` is not a list or convertible to a list.
2638
+
2639
+ ValueError
2640
+ If `in_array` is not a 4D array, if `vols_to_delete` is empty,
2641
+ or if any specified volume indices are out of range.
2642
+
2643
+ Notes
2644
+ - The function checks for valid input types and dimensions.
2645
+ - It handles both individual volume indices and ranges specified as tuples or strings.
2646
+ - The output array retains the original shape minus the removed volumes.
2647
+ - If all volumes are specified for deletion, it returns the original array unchanged.
2648
+
2649
+
2650
+ Examples
2651
+ --------
2652
+ **Basic usage - Remove specific volumes:**
2653
+
2654
+ >>> delete_volumes_from_4D_array(
2655
+ ... in_array=np.random.rand(64, 64, 30, 100), # Example 4D array
2656
+ ... vols_to_delete=[0, 1, 2], # Remove first 3 volumes
2657
+ ... )
2658
+
2659
+ (array with shape (64, 64, 30, 97), [0, 1, 2])
2660
+
2661
+ **Remove a range of volumes:**
2662
+ >>> delete_volumes_from_4D_array(
2663
+ ... in_array=np.random.rand(64, 64, 30, 100), # Example 4D array
2664
+ ... vols_to_delete=[(5, 8)], # Remove volumes 5 to 8 (inclusive)
2665
+ ... )
2666
+ (array with shape (64, 64, 30, 96), [5, 6, 7, 8])
2667
+
2668
+ **Remove volumes using a string specification:**
2669
+ >>> delete_volumes_from_4D_array(
2670
+ ... in_array=np.random.rand(64, 64, 30, 100), # Example 4D array
2671
+ ... vols_to_delete=["0-2", "5", "10:2:14", "20"] # Mixed specification
2672
+ ... )
2673
+ (array with shape (64, 64, 30, 92), [0, 1, 2, 5, 10, 12, 14, 20])
2674
+
2675
+ """
2676
+
2677
+ # Ensure input is a numpy array
2678
+ if not isinstance(in_array, np.ndarray):
2679
+ raise TypeError("Input in_array must be a numpy ndarray.")
2680
+
2681
+ # Check if it is a 4D array
2682
+ if in_array.ndim != 4:
2683
+ raise ValueError(
2684
+ f"Input in_array must be a 4D array. It has {in_array.ndim} dimensions."
2685
+ )
2686
+
2687
+ # Ensure vols_to_delete is a list
2688
+ if not isinstance(vols_to_delete, list):
2689
+ vols_to_delete = [vols_to_delete]
2690
+
2691
+ # Convert vols_to_delete to a flat list of integers
2692
+ vols_to_delete = cltmisc.build_indices(vols_to_delete, nonzeros=False)
2693
+
2694
+ # Check if vols_to_delete is not empty
2695
+ if len(vols_to_delete) == 0:
2696
+ print("No volumes to delete. The volumes to delete list is empty.")
2697
+ return in_array, []
2698
+
2699
+ # Get the dimensions of the array
2700
+ dim = in_array.shape
2701
+
2702
+ # Get the number of volumes
2703
+ nvols = dim[3]
2704
+
2705
+ # Check if trying to delete all volumes
2706
+ if len(vols_to_delete) == nvols:
2707
+ print(
2708
+ "Number of volumes to delete is equal to the total number of volumes. No volumes will be deleted."
2709
+ )
2710
+ return in_array, []
2711
+
2712
+ # Check if volumes to delete are in valid range
2713
+ if np.max(vols_to_delete) >= nvols:
2714
+ vols_to_delete_array = np.array(vols_to_delete)
2715
+ out_of_range = np.where(vols_to_delete_array >= nvols)[0]
2716
+ raise ValueError(
2717
+ f"Volumes out of range: {vols_to_delete_array[out_of_range]}. "
2718
+ f"Values should be between 0 and {nvols-1}."
2719
+ )
2720
+
2721
+ if np.min(vols_to_delete) < 0:
2722
+ raise ValueError(
2723
+ f"Volumes to delete {vols_to_delete} contain negative indices. "
2724
+ f"Values should be between 0 and {nvols-1}."
2725
+ )
2726
+
2727
+ # Get volumes to keep and remove
2728
+ vols_to_remove = np.array(vols_to_delete)
2729
+ vols_to_keep = np.where(np.isin(np.arange(nvols), vols_to_remove, invert=True))[0]
2730
+
2731
+ # Remove the specified volumes from the input array
2732
+ out_array = in_array[:, :, :, vols_to_keep]
2733
+
2734
+ return out_array, list(vols_to_remove.tolist())