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.
- synthseg_mlx/__init__.py +82 -0
- synthseg_mlx/_lab2im/__init__.py +1 -0
- synthseg_mlx/_lab2im/edit_volumes.py +431 -0
- synthseg_mlx/_lab2im/utils.py +484 -0
- synthseg_mlx/_typing.py +156 -0
- synthseg_mlx/data/synthseg_denoiser_labels_2.0.npy +0 -0
- synthseg_mlx/data/synthseg_parcellation_labels.npy +0 -0
- synthseg_mlx/data/synthseg_parcellation_names.npy +0 -0
- synthseg_mlx/data/synthseg_qc_labels.npy +0 -0
- synthseg_mlx/data/synthseg_qc_labels_2.0.npy +0 -0
- synthseg_mlx/data/synthseg_qc_names.npy +0 -0
- synthseg_mlx/data/synthseg_qc_names_2.0.npy +0 -0
- synthseg_mlx/data/synthseg_segmentation_labels.npy +0 -0
- synthseg_mlx/data/synthseg_segmentation_labels_2.0.npy +0 -0
- synthseg_mlx/data/synthseg_segmentation_names.npy +0 -0
- synthseg_mlx/data/synthseg_segmentation_names_2.0.npy +0 -0
- synthseg_mlx/data/synthseg_topological_classes.npy +0 -0
- synthseg_mlx/data/synthseg_topological_classes_2.0.npy +0 -0
- synthseg_mlx/label_data.py +62 -0
- synthseg_mlx/labels.py +126 -0
- synthseg_mlx/medmlx.py +754 -0
- synthseg_mlx/mlx_engine.py +169 -0
- synthseg_mlx/mlx_model.py +933 -0
- synthseg_mlx/pinned_weights.py +33 -0
- synthseg_mlx/processing.py +274 -0
- synthseg_mlx/py.typed +0 -0
- synthseg_mlx/variants.py +69 -0
- synthseg_mlx-0.2.1.dist-info/METADATA +66 -0
- synthseg_mlx-0.2.1.dist-info/RECORD +33 -0
- synthseg_mlx-0.2.1.dist-info/WHEEL +4 -0
- synthseg_mlx-0.2.1.dist-info/entry_points.txt +2 -0
- synthseg_mlx-0.2.1.dist-info/licenses/LICENSE +202 -0
- synthseg_mlx-0.2.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +309 -0
synthseg_mlx/__init__.py
ADDED
|
@@ -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()
|