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