synthseg-mlx 0.2.1__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.

Potentially problematic release.


This version of synthseg-mlx might be problematic. Click here for more details.

Files changed (33) hide show
  1. synthseg_mlx/__init__.py +82 -0
  2. synthseg_mlx/_lab2im/__init__.py +1 -0
  3. synthseg_mlx/_lab2im/edit_volumes.py +431 -0
  4. synthseg_mlx/_lab2im/utils.py +484 -0
  5. synthseg_mlx/_typing.py +156 -0
  6. synthseg_mlx/data/synthseg_denoiser_labels_2.0.npy +0 -0
  7. synthseg_mlx/data/synthseg_parcellation_labels.npy +0 -0
  8. synthseg_mlx/data/synthseg_parcellation_names.npy +0 -0
  9. synthseg_mlx/data/synthseg_qc_labels.npy +0 -0
  10. synthseg_mlx/data/synthseg_qc_labels_2.0.npy +0 -0
  11. synthseg_mlx/data/synthseg_qc_names.npy +0 -0
  12. synthseg_mlx/data/synthseg_qc_names_2.0.npy +0 -0
  13. synthseg_mlx/data/synthseg_segmentation_labels.npy +0 -0
  14. synthseg_mlx/data/synthseg_segmentation_labels_2.0.npy +0 -0
  15. synthseg_mlx/data/synthseg_segmentation_names.npy +0 -0
  16. synthseg_mlx/data/synthseg_segmentation_names_2.0.npy +0 -0
  17. synthseg_mlx/data/synthseg_topological_classes.npy +0 -0
  18. synthseg_mlx/data/synthseg_topological_classes_2.0.npy +0 -0
  19. synthseg_mlx/label_data.py +62 -0
  20. synthseg_mlx/labels.py +126 -0
  21. synthseg_mlx/medmlx.py +754 -0
  22. synthseg_mlx/mlx_engine.py +169 -0
  23. synthseg_mlx/mlx_model.py +933 -0
  24. synthseg_mlx/pinned_weights.py +33 -0
  25. synthseg_mlx/processing.py +274 -0
  26. synthseg_mlx/py.typed +0 -0
  27. synthseg_mlx/variants.py +69 -0
  28. synthseg_mlx-0.2.1.dist-info/METADATA +66 -0
  29. synthseg_mlx-0.2.1.dist-info/RECORD +33 -0
  30. synthseg_mlx-0.2.1.dist-info/WHEEL +4 -0
  31. synthseg_mlx-0.2.1.dist-info/entry_points.txt +2 -0
  32. synthseg_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
  33. synthseg_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +309 -0
@@ -0,0 +1,82 @@
1
+ """Native MLX implementation of SynthSeg.
2
+
3
+ Public names load on first access, so importing the package (or the medmlx
4
+ declaration in ``synthseg_mlx.medmlx``) does not import MLX.
5
+ """
6
+
7
+ from importlib import import_module
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from synthseg_mlx.label_data import load_label_data as load_label_data
12
+ from synthseg_mlx.labels import get_flip_indices as get_flip_indices
13
+ from synthseg_mlx.mlx_engine import postprocess_arrays as postprocess_arrays
14
+ from synthseg_mlx.mlx_engine import predict_numpy as predict_numpy
15
+ from synthseg_mlx.mlx_engine import prepare_network_input as prepare_network_input
16
+ from synthseg_mlx.mlx_engine import qc_scores as qc_scores
17
+ from synthseg_mlx.mlx_model import BatchNorm3d as BatchNorm3d
18
+ from synthseg_mlx.mlx_model import CheckpointLayers as CheckpointLayers
19
+ from synthseg_mlx.mlx_model import QCNet as QCNet
20
+ from synthseg_mlx.mlx_model import SynthSegModel as SynthSegModel
21
+ from synthseg_mlx.mlx_model import UNet as UNet
22
+ from synthseg_mlx.mlx_model import crop_for_qc as crop_for_qc
23
+ from synthseg_mlx.mlx_model import h5_kernel_to_mlx as h5_kernel_to_mlx
24
+ from synthseg_mlx.pinned_weights import verify_pinned_h5 as verify_pinned_h5
25
+ from synthseg_mlx.processing import postprocess as postprocess
26
+ from synthseg_mlx.processing import preprocess as preprocess
27
+ from synthseg_mlx.variants import SynthSegVariant as SynthSegVariant
28
+
29
+ __version__ = "0.2.1"
30
+
31
+ _EXPORTS = {
32
+ "BatchNorm3d": "synthseg_mlx.mlx_model",
33
+ "CheckpointLayers": "synthseg_mlx.mlx_model",
34
+ "QCNet": "synthseg_mlx.mlx_model",
35
+ "SynthSegModel": "synthseg_mlx.mlx_model",
36
+ "SynthSegVariant": "synthseg_mlx.variants",
37
+ "UNet": "synthseg_mlx.mlx_model",
38
+ "crop_for_qc": "synthseg_mlx.mlx_model",
39
+ "get_flip_indices": "synthseg_mlx.labels",
40
+ "h5_kernel_to_mlx": "synthseg_mlx.mlx_model",
41
+ "load_label_data": "synthseg_mlx.label_data",
42
+ "postprocess": "synthseg_mlx.processing",
43
+ "postprocess_arrays": "synthseg_mlx.mlx_engine",
44
+ "predict_numpy": "synthseg_mlx.mlx_engine",
45
+ "prepare_network_input": "synthseg_mlx.mlx_engine",
46
+ "preprocess": "synthseg_mlx.processing",
47
+ "qc_scores": "synthseg_mlx.mlx_engine",
48
+ "verify_pinned_h5": "synthseg_mlx.pinned_weights",
49
+ }
50
+
51
+ __all__ = [
52
+ "BatchNorm3d",
53
+ "CheckpointLayers",
54
+ "QCNet",
55
+ "SynthSegModel",
56
+ "SynthSegVariant",
57
+ "UNet",
58
+ "crop_for_qc",
59
+ "get_flip_indices",
60
+ "h5_kernel_to_mlx",
61
+ "load_label_data",
62
+ "postprocess",
63
+ "postprocess_arrays",
64
+ "predict_numpy",
65
+ "prepare_network_input",
66
+ "preprocess",
67
+ "qc_scores",
68
+ "verify_pinned_h5",
69
+ ]
70
+
71
+
72
+ def __getattr__(name: str) -> object:
73
+ module = _EXPORTS.get(name)
74
+ if module is None:
75
+ raise AttributeError(f"module 'synthseg_mlx' has no attribute {name!r}")
76
+ value = getattr(import_module(module), name)
77
+ globals()[name] = value
78
+ return value
79
+
80
+
81
+ def __dir__() -> list[str]:
82
+ return sorted({*globals(), *_EXPORTS})
@@ -0,0 +1 @@
1
+ """NumPy/SciPy helpers retained from the pinned SynthSeg lab2im source."""
@@ -0,0 +1,431 @@
1
+ """
2
+ This file contains functions to edit/preprocess volumes (i.e. not tensors!).
3
+ These functions are sorted in five categories:
4
+ 1- volume editing: this can be applied to any volume (i.e. images or label maps). It contains:
5
+ -mask_volume
6
+ -rescale_volume
7
+ -crop_volume
8
+ -crop_volume_around_region
9
+ -crop_volume_with_idx
10
+ -pad_volume
11
+ -flip_volume
12
+ -resample_volume
13
+ -resample_volume_like
14
+ -get_ras_axes
15
+ -align_volume_to_ref
16
+ -blur_volume
17
+ 2- label map editing: can be applied to label maps only. It contains:
18
+ -correct_label_map
19
+ -mask_label_map
20
+ -smooth_label_map
21
+ -erode_label_map
22
+ -get_largest_connected_component
23
+ -compute_hard_volumes
24
+ -compute_distance_map
25
+ 3- editing all volumes in a folder: functions are more or less the same as 1, but they now apply to all the volumes
26
+ in a given folder. Thus we provide folder paths rather than numpy arrays as inputs. It contains:
27
+ -mask_images_in_dir
28
+ -rescale_images_in_dir
29
+ -crop_images_in_dir
30
+ -crop_images_around_region_in_dir
31
+ -pad_images_in_dir
32
+ -flip_images_in_dir
33
+ -align_images_in_dir
34
+ -correct_nans_images_in_dir
35
+ -blur_images_in_dir
36
+ -create_mutlimodal_images
37
+ -convert_images_in_dir_to_nifty
38
+ -mri_convert_images_in_dir
39
+ -samseg_images_in_dir
40
+ -niftyreg_images_in_dir
41
+ -upsample_anisotropic_images
42
+ -simulate_upsampled_anisotropic_images
43
+ -check_images_in_dir
44
+ 4- label maps in dir: same as 3 but for label map-specific functions. It contains:
45
+ -correct_labels_in_dir
46
+ -mask_labels_in_dir
47
+ -smooth_labels_in_dir
48
+ -erode_labels_in_dir
49
+ -upsample_labels_in_dir
50
+ -compute_hard_volumes_in_dir
51
+ -build_atlas
52
+ 5- dataset editing: functions for editing datasets (i.e. images with corresponding label maps). It contains:
53
+ -check_images_and_labels
54
+ -crop_dataset_to_minimum_size
55
+ -subdivide_dataset_to_patches
56
+
57
+
58
+ If you use this code, please cite the first SynthSeg paper:
59
+ https://github.com/BBillot/lab2im/blob/master/bibtex.bib
60
+
61
+ Copyright 2020 Benjamin Billot
62
+
63
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in
64
+ compliance with the License. You may obtain a copy of the License at
65
+ https://www.apache.org/licenses/LICENSE-2.0
66
+ Unless required by applicable law or agreed to in writing, software distributed under the License is
67
+ distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
68
+ implied. See the License for the specific language governing permissions and limitations under the
69
+ License.
70
+ """
71
+
72
+ from __future__ import annotations
73
+
74
+ from typing import Literal, cast, overload
75
+
76
+ # Extracted subset; imports and package boundaries adapted for synthseg-mlx.
77
+ import numpy as np
78
+ from scipy.interpolate import RegularGridInterpolator
79
+ from scipy.ndimage import gaussian_filter, map_coordinates
80
+ from scipy.ndimage import label as scipy_label
81
+
82
+ from synthseg_mlx._lab2im import utils
83
+ from synthseg_mlx._typing import (
84
+ BoolArray,
85
+ DoubleArray,
86
+ FloatGridApi,
87
+ IndexArray,
88
+ IntArray,
89
+ IntegerGridApi,
90
+ NumericScalar,
91
+ RealArray,
92
+ Resolution,
93
+ Size,
94
+ )
95
+
96
+
97
+ def rescale_volume(volume: RealArray, new_min: float=0, new_max: float=255, min_percentile: float=2, max_percentile: float=98, use_positive_only: bool=False) -> RealArray:
98
+ """This function linearly rescales a volume between new_min and new_max.
99
+ :param volume: a numpy array
100
+ :param new_min: (optional) minimum value for the rescaled image.
101
+ :param new_max: (optional) maximum value for the rescaled image.
102
+ :param min_percentile: (optional) percentile for estimating robust minimum of volume (float in [0,...100]),
103
+ where 0 = np.min
104
+ :param max_percentile: (optional) percentile for estimating robust maximum of volume (float in [0,...100]),
105
+ where 100 = np.max
106
+ :param use_positive_only: (optional) whether to use only positive values when estimating the min and max percentile
107
+ :return: rescaled volume
108
+ """
109
+
110
+ # select only positive intensities
111
+ new_volume = volume.copy()
112
+ intensities = new_volume[new_volume > 0] if use_positive_only else new_volume.flatten()
113
+
114
+ # define min and max intensities in original image for normalisation
115
+ robust_min = cast(NumericScalar, np.min(intensities) if min_percentile == 0 else np.percentile(intensities, min_percentile))
116
+ robust_max = cast(NumericScalar, np.max(intensities) if max_percentile == 100 else np.percentile(intensities, max_percentile))
117
+
118
+ # trim values outside range
119
+ new_volume = np.clip(new_volume, robust_min, robust_max)
120
+
121
+ # rescale image
122
+ if robust_min != robust_max:
123
+ return cast(RealArray, new_min + (new_volume - robust_min) / (robust_max - robust_min) * (new_max - new_min))
124
+ else: # avoid dividing by zero
125
+ return np.zeros_like(new_volume)
126
+
127
+
128
+ @overload
129
+ def crop_volume[V: RealArray](volume: V, cropping_margin: Size | None = None, cropping_shape: Size | None = None, aff: None = None, return_crop_idx: Literal[False] = False, mode: str = "center") -> V: ...
130
+ @overload
131
+ def crop_volume[V: RealArray](volume: V, cropping_margin: Size | None = None, cropping_shape: Size | None = None, aff: None = None, *, return_crop_idx: Literal[True], mode: str = "center") -> tuple[V, IndexArray]: ...
132
+ @overload
133
+ def crop_volume[V: RealArray](volume: V, cropping_margin: Size | None = None, cropping_shape: Size | None = None, *, aff: DoubleArray, return_crop_idx: Literal[False] = False, mode: str = "center") -> tuple[V, DoubleArray]: ...
134
+ @overload
135
+ def crop_volume[V: RealArray](volume: V, cropping_margin: Size | None = None, cropping_shape: Size | None = None, *, aff: DoubleArray, return_crop_idx: Literal[True], mode: str = "center") -> tuple[V, DoubleArray, IndexArray]: ...
136
+ @overload
137
+ def crop_volume[V: RealArray](volume: V, cropping_margin: Size | None = None, cropping_shape: Size | None = None, aff: DoubleArray | None = None, return_crop_idx: bool = False, mode: str = "center") -> V | tuple[RealArray, ...]: ...
138
+
139
+ def crop_volume(volume: RealArray, cropping_margin: Size | None=None, cropping_shape: Size | None=None, aff: DoubleArray | None=None, return_crop_idx: bool=False, mode: str='center') -> RealArray | tuple[RealArray, ...]:
140
+ """Crop volume by a given margin, or to a given shape.
141
+ :param volume: 2d or 3d numpy array (possibly with multiple channels)
142
+ :param cropping_margin: (optional) margin by which to crop the volume. The cropping margin is applied on both sides.
143
+ Can be an int, sequence or 1d numpy array of size n_dims. Should be given if cropping_shape is None.
144
+ :param cropping_shape: (optional) shape to which the volume will be cropped. Can be an int, sequence or 1d numpy
145
+ array of size n_dims. Should be given if cropping_margin is None.
146
+ :param aff: (optional) affine matrix of the input volume.
147
+ If not None, this function also returns an updated version of the affine matrix for the cropped volume.
148
+ :param return_crop_idx: (optional) whether to return the cropping indices used to crop the given volume.
149
+ :param mode: (optional) if cropping_shape is not None, whether to extract the centre of the image (mode='center'),
150
+ or to randomly crop the volume to the provided shape (mode='random'). Default is 'center'.
151
+ :return: cropped volume, corresponding affine matrix if aff is not None, and cropping indices if return_crop_idx is
152
+ True (in that order).
153
+ """
154
+
155
+ assert (cropping_margin is not None) | (cropping_shape is not None), \
156
+ 'cropping_margin or cropping_shape should be provided'
157
+ assert not ((cropping_margin is not None) & (cropping_shape is not None)), \
158
+ 'only one of cropping_margin or cropping_shape should be provided'
159
+
160
+ # get info
161
+ new_volume = volume.copy()
162
+ vol_shape = new_volume.shape
163
+ n_dims, _ = utils.get_dims(vol_shape)
164
+
165
+ # find cropping indices
166
+ if cropping_margin is not None:
167
+ cropping_margin = cast(list[int], utils.reformat_to_list(cropping_margin, length=n_dims))
168
+ do_cropping = np.array(vol_shape[:n_dims]) > 2 * np.array(cropping_margin)
169
+ min_crop_idx = [cropping_margin[i] if do_cropping[i] else 0 for i in range(n_dims)]
170
+ max_crop_idx = [vol_shape[i] - cropping_margin[i] if do_cropping[i] else vol_shape[i] for i in range(n_dims)]
171
+ else:
172
+ cropping_shape = cast(list[int], utils.reformat_to_list(cropping_shape, length=n_dims))
173
+ if mode == 'center':
174
+ min_crop_idx = np.maximum([int((vol_shape[i] - cropping_shape[i]) / 2) for i in range(n_dims)], 0)
175
+ max_crop_idx = np.minimum([min_crop_idx[i] + cropping_shape[i] for i in range(n_dims)],
176
+ np.array(vol_shape)[:n_dims])
177
+ elif mode == 'random':
178
+ crop_max_val = np.maximum(np.array([vol_shape[i] - cropping_shape[i] for i in range(n_dims)]), 0)
179
+ min_crop_idx = np.random.randint(0, high=crop_max_val + 1)
180
+ max_crop_idx = np.minimum(min_crop_idx + np.array(cropping_shape), np.array(vol_shape)[:n_dims])
181
+ else:
182
+ raise ValueError('mode should be either "center" or "random", had %s' % mode)
183
+ crop_idx = np.concatenate([np.array(min_crop_idx), np.array(max_crop_idx)])
184
+
185
+ # crop volume
186
+ if n_dims == 2:
187
+ new_volume = new_volume[crop_idx[0]: crop_idx[2], crop_idx[1]: crop_idx[3], ...]
188
+ elif n_dims == 3:
189
+ new_volume = new_volume[crop_idx[0]: crop_idx[3], crop_idx[1]: crop_idx[4], crop_idx[2]: crop_idx[5], ...]
190
+
191
+ # sort outputs
192
+ output = [new_volume]
193
+ if aff is not None:
194
+ aff[0:3, -1] = aff[0:3, -1] + aff[:3, :3] @ np.array(min_crop_idx)
195
+ output.append(aff)
196
+ if return_crop_idx:
197
+ output.append(crop_idx)
198
+ return output[0] if len(output) == 1 else tuple(output)
199
+
200
+
201
+ @overload
202
+ def crop_volume_with_idx[V: RealArray](volume: V, crop_idx: IndexArray, aff: None = None, n_dims: int | None = None, return_copy: bool = True) -> V: ...
203
+ @overload
204
+ def crop_volume_with_idx[V: RealArray](volume: V, crop_idx: IndexArray, aff: DoubleArray, n_dims: int | None = None, return_copy: bool = True) -> tuple[V, DoubleArray]: ...
205
+ @overload
206
+ def crop_volume_with_idx[V: RealArray](volume: V, crop_idx: IndexArray, aff: DoubleArray | None = None, n_dims: int | None = None, return_copy: bool = True) -> V | tuple[V, DoubleArray]: ...
207
+
208
+ def crop_volume_with_idx(volume: RealArray, crop_idx: IndexArray, aff: DoubleArray | None=None, n_dims: int | None=None, return_copy: bool=True) -> RealArray | tuple[RealArray, RealArray]:
209
+ """Crop a volume with given indices.
210
+ :param volume: a 2d or 3d numpy array
211
+ :param crop_idx: cropping indices, in the order [lower_bound_dim_1, ..., upper_bound_dim_1, ...].
212
+ Can be a list or a 1d numpy array.
213
+ :param aff: (optional) if aff is specified, this function returns an updated affine matrix of the volume after
214
+ cropping.
215
+ :param n_dims: (optional) number of dimensions (excluding channels) of the volume. If not provided, n_dims will be
216
+ inferred from the input volume.
217
+ :param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
218
+ :return: the cropped volume, and the updated affine matrix if aff is not None.
219
+ """
220
+
221
+ # get info
222
+ new_volume = volume.copy() if return_copy else volume
223
+ n_dims = int(np.array(crop_idx).shape[0] / 2) if n_dims is None else n_dims
224
+
225
+ # crop image
226
+ if n_dims == 2:
227
+ new_volume = new_volume[crop_idx[0]:crop_idx[2], crop_idx[1]:crop_idx[3], ...]
228
+ elif n_dims == 3:
229
+ new_volume = new_volume[crop_idx[0]:crop_idx[3], crop_idx[1]:crop_idx[4], crop_idx[2]:crop_idx[5], ...]
230
+ else:
231
+ raise Exception('cannot crop volumes with more than 3 dimensions')
232
+
233
+ if aff is not None:
234
+ aff[0:3, -1] = aff[0:3, -1] + aff[:3, :3] @ crop_idx[:3]
235
+ return new_volume, aff
236
+ else:
237
+ return new_volume
238
+
239
+
240
+ @overload
241
+ def pad_volume[V: RealArray](volume: V, padding_shape: Size, padding_value: float = 0, aff: None = None, return_pad_idx: Literal[False] = False) -> V: ...
242
+ @overload
243
+ def pad_volume[V: RealArray](volume: V, padding_shape: Size, padding_value: float = 0, aff: None = None, *, return_pad_idx: Literal[True]) -> tuple[V, IndexArray]: ...
244
+ @overload
245
+ def pad_volume[V: RealArray](volume: V, padding_shape: Size, padding_value: float = 0, *, aff: DoubleArray, return_pad_idx: Literal[False] = False) -> tuple[V, DoubleArray]: ...
246
+ @overload
247
+ def pad_volume[V: RealArray](volume: V, padding_shape: Size, padding_value: float = 0, *, aff: DoubleArray, return_pad_idx: Literal[True]) -> tuple[V, DoubleArray, IndexArray]: ...
248
+ @overload
249
+ def pad_volume[V: RealArray](volume: V, padding_shape: Size, padding_value: float = 0, aff: DoubleArray | None = None, return_pad_idx: bool = False) -> V | tuple[RealArray, ...]: ...
250
+
251
+ def pad_volume(volume: RealArray, padding_shape: Size, padding_value: float=0, aff: DoubleArray | None=None, return_pad_idx: bool=False) -> RealArray | tuple[RealArray, ...]:
252
+ """Pad volume to a given shape
253
+ :param volume: volume to be padded
254
+ :param padding_shape: shape to pad volume to. Can be a number, a sequence or a 1d numpy array.
255
+ :param padding_value: (optional) value used for padding
256
+ :param aff: (optional) affine matrix of the volume
257
+ :param return_pad_idx: (optional) the pad_idx corresponds to the indices where we should crop the resulting
258
+ padded image (ie the output of this function) to go back to the original volume (ie the input of this function).
259
+ :return: padded volume, and updated affine matrix if aff is not None.
260
+ """
261
+
262
+ # get info
263
+ new_volume = volume.copy()
264
+ vol_shape = new_volume.shape
265
+ n_dims, n_channels = utils.get_dims(vol_shape)
266
+ padding_shape = utils.reformat_to_list(padding_shape, length=n_dims, dtype='int')
267
+
268
+ # check if need to pad
269
+ if np.any(np.array(padding_shape, dtype='int32') > np.array(vol_shape[:n_dims], dtype='int32')):
270
+
271
+ # get padding margins
272
+ min_margins = np.maximum(np.int32(np.floor((np.array(padding_shape) - np.array(vol_shape)[:n_dims]) / 2)), 0)
273
+ max_margins = np.maximum(np.int32(np.ceil((np.array(padding_shape) - np.array(vol_shape)[:n_dims]) / 2)), 0)
274
+ pad_idx = np.concatenate([min_margins, min_margins + np.array(vol_shape[:n_dims])])
275
+ pad_margins = tuple([(min_margins[i], max_margins[i]) for i in range(n_dims)])
276
+ if n_channels > 1:
277
+ pad_margins = tuple(list(pad_margins) + [(0, 0)])
278
+
279
+ # pad volume
280
+ new_volume = np.pad(new_volume, pad_margins, mode='constant', constant_values=padding_value)
281
+
282
+ if aff is not None:
283
+ if n_dims == 2:
284
+ min_margins = np.append(min_margins, 0)
285
+ aff[:-1, -1] = aff[:-1, -1] - aff[:-1, :-1] @ min_margins
286
+
287
+ else:
288
+ pad_idx = np.concatenate([np.array([0] * n_dims), np.array(vol_shape[:n_dims])])
289
+
290
+ # sort outputs
291
+ output = [new_volume]
292
+ if aff is not None:
293
+ output.append(aff)
294
+ if return_pad_idx:
295
+ output.append(pad_idx)
296
+ return output[0] if len(output) == 1 else tuple(output)
297
+
298
+
299
+ def resample_volume(volume: RealArray, aff: DoubleArray, new_vox_size: Resolution, interpolation: str='linear', blur: bool=True) -> tuple[RealArray, DoubleArray]:
300
+ """This function resizes the voxels of a volume to a new provided size, while adjusting the header to keep the RAS
301
+ :param volume: a numpy array
302
+ :param aff: affine matrix of the volume
303
+ :param new_vox_size: new voxel size (3 - element numpy vector) in mm
304
+ :param interpolation: (optional) type of interpolation. Can be 'linear' or 'nearest'. Default is 'linear'.
305
+ :param blur: (optional) whether to blur before resampling to avoid aliasing effects.
306
+ Only used if the input volume is downsampled. Default is True.
307
+ :return: new volume and affine matrix
308
+ """
309
+
310
+ pixdim = np.sqrt(np.sum(aff * aff, axis=0))[:-1]
311
+ new_vox_size = np.array(new_vox_size)
312
+ factor = pixdim / new_vox_size
313
+ sigmas = 0.25 / factor
314
+ sigmas[factor > 1] = 0 # don't blur if upsampling
315
+
316
+ volume_filt = gaussian_filter(volume, sigmas) if blur else volume
317
+
318
+ start = - (factor - 1) / (2 * factor)
319
+ step = 1.0 / factor
320
+ stop = start + step * np.ceil(volume_filt.shape * factor)
321
+
322
+ xi = cast(FloatGridApi, np).arange(start=start[0], stop=stop[0], step=step[0])
323
+ yi = cast(FloatGridApi, np).arange(start=start[1], stop=stop[1], step=step[1])
324
+ zi = cast(FloatGridApi, np).arange(start=start[2], stop=stop[2], step=step[2])
325
+ xi[xi < 0] = 0
326
+ yi[yi < 0] = 0
327
+ zi[zi < 0] = 0
328
+ xi[xi > (volume_filt.shape[0] - 1)] = volume_filt.shape[0] - 1
329
+ yi[yi > (volume_filt.shape[1] - 1)] = volume_filt.shape[1] - 1
330
+ zi[zi > (volume_filt.shape[2] - 1)] = volume_filt.shape[2] - 1
331
+
332
+ xig, yig, zig = np.meshgrid(xi, yi, zi, indexing='ij', sparse=True)
333
+ if interpolation == 'linear':
334
+ # Keep the original grid, boundary clamping and float64 interpolation output.
335
+ coordinates = np.array(np.broadcast_arrays(xig, yig, zig))
336
+ volume2 = map_coordinates(volume_filt, coordinates, order=1, mode='nearest', prefilter=False,
337
+ output=np.result_type(volume_filt.dtype, np.float64))
338
+ else:
339
+ axes = tuple(cast(IntegerGridApi, np).arange(size) for size in volume_filt.shape)
340
+ interpolator = RegularGridInterpolator(axes, volume_filt, method=cast(Literal["linear", "nearest", "slinear", "cubic", "quintic", "pchip"], interpolation))
341
+ volume2 = interpolator((xig, yig, zig))
342
+
343
+ aff2 = aff.copy()
344
+ for c in range(3):
345
+ aff2[:-1, c] = aff2[:-1, c] / factor[c]
346
+ aff2[:-1, -1] = aff2[:-1, -1] - np.matmul(aff2[:-1, :-1], 0.5 * (factor - 1))
347
+
348
+ return volume2, aff2
349
+
350
+
351
+ def get_ras_axes(aff: DoubleArray, n_dims: int=3) -> IntArray:
352
+ """This function finds the RAS axes corresponding to each dimension of a volume, based on its affine matrix.
353
+ :param aff: affine matrix Can be a 2d numpy array of size n_dims*n_dims, n_dims+1*n_dims+1, or n_dims*n_dims+1.
354
+ :param n_dims: number of dimensions (excluding channels) of the volume corresponding to the provided affine matrix.
355
+ :return: two numpy 1d arrays of length n_dims, one with the axes corresponding to RAS orientations,
356
+ and one with their corresponding direction.
357
+ """
358
+ aff_inverted = np.linalg.inv(aff)
359
+ img_ras_axes = np.argmax(np.absolute(aff_inverted[0:n_dims, 0:n_dims]), axis=0)
360
+ for i in range(n_dims):
361
+ if i not in img_ras_axes:
362
+ unique, counts = np.unique(img_ras_axes, return_counts=True)
363
+ incorrect_value = unique[np.argmax(counts)]
364
+ img_ras_axes[np.where(img_ras_axes == incorrect_value)[0][-1]] = i
365
+
366
+ return img_ras_axes
367
+
368
+
369
+ @overload
370
+ def align_volume_to_ref[V: RealArray](volume: V, aff: DoubleArray, aff_ref: DoubleArray | None = None, return_aff: Literal[False] = False, n_dims: int | None = None, return_copy: bool = True) -> V: ...
371
+ @overload
372
+ def align_volume_to_ref[V: RealArray](volume: V, aff: DoubleArray, aff_ref: DoubleArray | None = None, *, return_aff: Literal[True], n_dims: int | None = None, return_copy: bool = True) -> tuple[V, DoubleArray]: ...
373
+ @overload
374
+ def align_volume_to_ref[V: RealArray](volume: V, aff: DoubleArray, aff_ref: DoubleArray | None = None, return_aff: bool = False, n_dims: int | None = None, return_copy: bool = True) -> V | tuple[V, DoubleArray]: ...
375
+
376
+ def align_volume_to_ref(volume: RealArray, aff: DoubleArray, aff_ref: DoubleArray | None=None, return_aff: bool=False, n_dims: int | None=None, return_copy: bool=True) -> RealArray | tuple[RealArray, RealArray]:
377
+ """This function aligns a volume to a reference orientation (axis and direction) specified by an affine matrix.
378
+ :param volume: a numpy array
379
+ :param aff: affine matrix of the floating volume
380
+ :param aff_ref: (optional) affine matrix of the target orientation. Default is identity matrix.
381
+ :param return_aff: (optional) whether to return the affine matrix of the aligned volume
382
+ :param n_dims: (optional) number of dimensions (excluding channels) of the volume. If not provided, n_dims will be
383
+ inferred from the input volume.
384
+ :param return_copy: (optional) whether to return the original volume or a copy. Default is copy.
385
+ :return: aligned volume, with corresponding affine matrix if return_aff is True.
386
+ """
387
+
388
+ # work on copy
389
+ new_volume = volume.copy() if return_copy else volume
390
+ aff_flo = aff.copy()
391
+
392
+ # default value for aff_ref
393
+ if aff_ref is None:
394
+ aff_ref = np.eye(4)
395
+
396
+ # extract ras axes
397
+ if n_dims is None:
398
+ n_dims, _ = utils.get_dims(new_volume.shape)
399
+ ras_axes_ref = get_ras_axes(aff_ref, n_dims=n_dims)
400
+ ras_axes_flo = get_ras_axes(aff_flo, n_dims=n_dims)
401
+
402
+ # align axes
403
+ aff_flo[:, ras_axes_ref] = aff_flo[:, ras_axes_flo]
404
+ for i in range(n_dims):
405
+ if ras_axes_flo[i] != ras_axes_ref[i]:
406
+ new_volume = np.swapaxes(new_volume, ras_axes_flo[i], ras_axes_ref[i])
407
+ swapped_axis_idx = np.where(ras_axes_flo == ras_axes_ref[i])
408
+ # NumPy 2 requires an explicit scalar for this one-element assignment.
409
+ ras_axes_flo[swapped_axis_idx], ras_axes_flo[i] = ras_axes_flo[i], ras_axes_flo[swapped_axis_idx].item()
410
+
411
+ # align directions
412
+ dot_products = np.sum(aff_flo[:3, :3] * aff_ref[:3, :3], axis=0)
413
+ for i in range(n_dims):
414
+ if dot_products[i] < 0:
415
+ new_volume = np.flip(new_volume, axis=i)
416
+ aff_flo[:, i] = - aff_flo[:, i]
417
+ aff_flo[:3, 3] = aff_flo[:3, 3] - aff_flo[:3, i] * (new_volume.shape[i] - 1)
418
+
419
+ if return_aff:
420
+ return new_volume, aff_flo
421
+ else:
422
+ return new_volume
423
+
424
+
425
+ def get_largest_connected_component(mask: BoolArray, structure: BoolArray | None=None) -> BoolArray:
426
+ """Function to get the largest connected component for a given input.
427
+ :param mask: a 2d or 3d label map of boolean type.
428
+ :param structure: numpy array defining the connectivity.
429
+ """
430
+ components, n_components = scipy_label(mask, structure)
431
+ return components == np.argmax(np.bincount(components.flat)[1:]) + 1 if n_components > 0 else mask.copy()