mri-normalization-tools 0.4.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.
Files changed (45) hide show
  1. mnts/__init__.py +9 -0
  2. mnts/filters/__init__.py +9 -0
  3. mnts/filters/data_node.py +119 -0
  4. mnts/filters/geom/__init__.py +3 -0
  5. mnts/filters/geom/geom_mask_crop.py +276 -0
  6. mnts/filters/geom/reorient_filter.py +49 -0
  7. mnts/filters/geom/spatial_norm.py +89 -0
  8. mnts/filters/intensity/__init__.py +7 -0
  9. mnts/filters/intensity/hist_piecewise.py +498 -0
  10. mnts/filters/intensity/in_wrapper.py +19 -0
  11. mnts/filters/intensity/intensity_base.py +52 -0
  12. mnts/filters/intensity/linear_rescale.py +75 -0
  13. mnts/filters/intensity/linear_z_score.py +45 -0
  14. mnts/filters/intensity/masking.py +167 -0
  15. mnts/filters/intensity/n4_bias_field_correction.py +97 -0
  16. mnts/filters/intensity/range_rescale.py +87 -0
  17. mnts/filters/intensity/si_rebinning.py +65 -0
  18. mnts/filters/mnts_filters.py +112 -0
  19. mnts/filters/mnts_filters_graph.py +737 -0
  20. mnts/filters/mpi_wrapper.py +37 -0
  21. mnts/io/__init__.py +1 -0
  22. mnts/io/data_formatting.py +737 -0
  23. mnts/io/dixon.py +93 -0
  24. mnts/mnts_logger.py +688 -0
  25. mnts/scripts/__init__.py +3 -0
  26. mnts/scripts/console_entry.py +39 -0
  27. mnts/scripts/dicom2nii.py +140 -0
  28. mnts/scripts/dicom_anon.py +10 -0
  29. mnts/scripts/dicom_tag_printer.py +377 -0
  30. mnts/scripts/normalization.py +188 -0
  31. mnts/scripts/organize_nifti.py +162 -0
  32. mnts/utils/__init__.py +4 -0
  33. mnts/utils/dcm_anonymize.py +125 -0
  34. mnts/utils/dicom_tag_printer.py +1532 -0
  35. mnts/utils/filename_globber.py +245 -0
  36. mnts/utils/histogram_analysis.py +149 -0
  37. mnts/utils/preprocessing.py +194 -0
  38. mnts/utils/sequence_check.py +209 -0
  39. mnts/utils/utils.py +184 -0
  40. mri_normalization_tools-0.4.1.dist-info/METADATA +372 -0
  41. mri_normalization_tools-0.4.1.dist-info/RECORD +45 -0
  42. mri_normalization_tools-0.4.1.dist-info/WHEEL +5 -0
  43. mri_normalization_tools-0.4.1.dist-info/entry_points.txt +6 -0
  44. mri_normalization_tools-0.4.1.dist-info/licenses/LICENSE +21 -0
  45. mri_normalization_tools-0.4.1.dist-info/top_level.txt +1 -0
mnts/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ import SimpleITK as sitk
2
+ sitk.ProcessObject_GlobalWarningDisplayOff()
3
+
4
+ from . import filters
5
+ from . import io
6
+ from . import utils
7
+
8
+ __all__ = ['filters', 'io', 'utils']
9
+
@@ -0,0 +1,9 @@
1
+ from .mnts_filters import *
2
+ from .mnts_filters_graph import *
3
+ from .mpi_wrapper import *
4
+ from .data_node import *
5
+
6
+ from . import geom
7
+ from . import intensity
8
+
9
+ __all__ = ['geom', 'intensity']
@@ -0,0 +1,119 @@
1
+ from .mnts_filters import *
2
+ from typing import Any, Optional
3
+ import SimpleITK as sitk
4
+
5
+ __all__ = ["TypeCastNode", "DataNode"]
6
+
7
+ # SimpleITK >= 2.x inserted `outputPixelType` as the 2nd positional arg to Clamp.
8
+ # Detect once at import time so the right call path is used regardless of version.
9
+ _SITK_CLAMP_HAS_OUTPUT_TYPE = "outputPixelType" in (sitk.Clamp.__doc__ or "")
10
+
11
+ def _sitk_clamp(image: sitk.Image, lower: float, upper: float) -> sitk.Image:
12
+ if _SITK_CLAMP_HAS_OUTPUT_TYPE:
13
+ return sitk.Clamp(image, sitk.sitkUnknown, lower, upper)
14
+ return sitk.Clamp(image, lower, upper)
15
+
16
+ class DataNode(MNTSFilter):
17
+ r"""
18
+ Presents whatever data stored in this node. This is useful for storing intermediate results, or other data that
19
+ are repeatedly accessed without the need of re-computing. This can be used as the entrance node in the directed
20
+ graphs. See :class:`MNTSFilterGraph` for more.
21
+ """
22
+ def __init__(self, data=None):
23
+ super(DataNode, self).__init__()
24
+ self.data = data
25
+
26
+ @property
27
+ def data(self):
28
+ return self._data
29
+
30
+ @data.setter
31
+ def data(self, input: Any):
32
+ self._logger.info(f"Setting data ({type(input)}) {input}")
33
+ if isinstance(input, str):
34
+ input = self.read_image(input)
35
+ self._data = input
36
+
37
+ def clear_data(self):
38
+ self._data = None
39
+
40
+ def filter(self,
41
+ input: str or sitk.Image) -> Any:
42
+ self.data = input
43
+ return self.data
44
+
45
+ class TypeCastNode(MNTSFilter):
46
+ r"""
47
+ A class to cast the type to a specific datatype using `sitk.Cast`.
48
+ It is recommended to keep a clear track of the type casting within a pipeline or a graph.
49
+
50
+ Attributes:
51
+ target_type (int): The target type to which the input will be casted.
52
+ target_type_name (str): The name of the target type.
53
+ """
54
+ def __init__(self,
55
+ target_type: int = sitk.sitkInt16):
56
+ self._target_type = target_type
57
+ self._target_type_name = sitk.GetPixelIDValueAsString(self._target_type)
58
+ self._overflow_protection = {
59
+ sitk.sitkUInt8: (0, int(2**8.) - 1),
60
+ sitk.sitkUInt16: (0, int(2**16.) - 1),
61
+ sitk.sitkUInt32: (0, int(2**32.) - 1),
62
+ sitk.sitkUInt64: (0, int(2**64.) - 1),
63
+ sitk.sitkInt8: (-int(2**7.), int(2**7.) -1),
64
+ sitk.sitkInt16: (-int(2**15.), int(2 ** 15.) - 1),
65
+ sitk.sitkInt32: (-int(2**31.), int(2 ** 31.) - 1),
66
+ sitk.sitkInt64: (-int(2**63.), int(2 ** 63.) - 1)
67
+ # Ignore float numbers
68
+ }
69
+
70
+ @property
71
+ def target_type(self) -> Any:
72
+ r"""Return the target type as sitk code."""
73
+ return self._target_type
74
+
75
+ @target_type.setter
76
+ def target_type(self, val) -> None:
77
+ r"""Set the target type with sitk codes."""
78
+ self._target_type = val
79
+
80
+ @property
81
+ def target_type_name(self) -> str:
82
+ r"""Return the target type as string."""
83
+ return self._target_type_name
84
+
85
+ def filter(self, input: sitk.Image, ref_img: Optional[sitk.Image]=None) -> sitk.Image:
86
+ """Filter method to cast the type of the input.
87
+
88
+ If `ref_img` is not None, then the type cast references the type of it.
89
+ Otherwise, the type is casted as the attribute `self._target_type`.
90
+
91
+ Args:
92
+ input (sitk.Image):
93
+ The input to be casted.
94
+ ref_img (sitk.Image, optional):
95
+ The reference image to determine the type. Defaults to None.
96
+
97
+ Returns:
98
+ The input casted to the target type.
99
+
100
+ Raises:
101
+ ArithmeticError: If the type cast fails.
102
+ """
103
+ input = self.read_image(input)
104
+ target_type = self._target_type if ref_img is None else ref_img.GetPixelID()
105
+ try:
106
+ # Overflow protection
107
+ range = self._overflow_protection.get(target_type, None)
108
+ if range is not None:
109
+ f = sitk.MinimumMaximumImageFilter()
110
+ f.Execute(input)
111
+
112
+ max_val = f.GetMaximum()
113
+ min_val = f.GetMinimum()
114
+
115
+ if max_val > range[1] or min_val < range[0]:
116
+ input = _sitk_clamp(input, range[0], range[1])
117
+ return sitk.Cast(input, self._target_type)
118
+ except Exception as e:
119
+ raise ArithmeticError(f"Type cast failed in filter with parameters: {self.__str__()}") from e
@@ -0,0 +1,3 @@
1
+ from .spatial_norm import *
2
+ from .geom_mask_crop import *
3
+ from .reorient_filter import *
@@ -0,0 +1,276 @@
1
+ import SimpleITK as sitk
2
+ import numpy as np
3
+ from pathlib import Path
4
+ from typing import Union, Tuple, Optional
5
+ from ..mnts_filters import MNTSFilter
6
+
7
+
8
+ __all__ = ['RemoveShoulder']
9
+
10
+
11
+ class RemoveShoulder(MNTSFilter):
12
+ r"""
13
+ A geometric filter that takes a mask and calculates the slice area along a specified dimension,
14
+ then crops the 3D volume to a single slice along that dimension.
15
+
16
+ This filter finds the slice with the smallest mask area along the specified dimension and
17
+ reduces the 3D volume to just that single slice. The search of slice is done from inferior to superior
18
+ for identifying the shoulder. To prevent top slices (top of the head) to be accidentedly selected, a
19
+ barrier setting is implement that protects the top n slices from being selected.
20
+
21
+ The filter only crops along the specified dimension, leaving the other two dimensions at full size.
22
+ This is useful for extracting representative slices for analysis or preprocessing.
23
+
24
+ Attributes:
25
+ min_area_threshold (float):
26
+ Minimum area threshold as a fraction of the maximum area. Slices with area below
27
+ this threshold will be ignored to avoid noise. Default is 0.1 (10% of max area).
28
+
29
+ barrier (int):
30
+ Number of slices to count downwards from when searching for the minimum area slice.
31
+ The search starts from the end of the volume and works backwards by this many slices.
32
+ Default is 10 slices.
33
+
34
+ [Experimental] dimension (int):
35
+ This is fixed to 0 until a better way of fixing the direction of nifti is found.
36
+
37
+ Example:
38
+ >>> from mnts.filters.geom import RemoveShoulder
39
+ >>> crop_filter = RemoveShoulder(barrier=15, dimension=0)
40
+ >>> cropped_image = crop_filter.filter(image, mask)
41
+ """
42
+
43
+ def __init__(self,
44
+ min_area_threshold: float = 0.1,
45
+ barrier: int = 10):
46
+ super(RemoveShoulder, self).__init__()
47
+ self.min_area_threshold = min_area_threshold
48
+ self.barrier = barrier
49
+ self.dimension = 0 # This is now fixed
50
+
51
+ self._is_reversed = False
52
+
53
+ @property
54
+ def min_area_threshold(self):
55
+ return self._min_area_threshold
56
+
57
+ @min_area_threshold.setter
58
+ def min_area_threshold(self, threshold: float):
59
+ if not 0 <= threshold <= 1:
60
+ raise ValueError("Min area threshold must be between 0 and 1")
61
+ self._min_area_threshold = threshold
62
+
63
+ @property
64
+ def barrier(self):
65
+ return self._barrier
66
+
67
+ @barrier.setter
68
+ def barrier(self, val: float):
69
+ assert 0 <= val
70
+ self._barrier = val
71
+
72
+ def filter(self,
73
+ image: Union[str, Path, sitk.Image],
74
+ mask: Union[str, Path, sitk.Image]) -> sitk.Image:
75
+ """
76
+ Apply geometric mask-based cropping to the input image.
77
+ """
78
+ image = self.read_image(image)
79
+ mask = self.read_image(mask)
80
+ size_before = image.GetSize()
81
+
82
+ if image.GetSize() != mask.GetSize():
83
+ raise RuntimeError("Image and mask must have the same dimensions")
84
+
85
+ if image.GetSpacing() != mask.GetSpacing():
86
+ self._logger.warning("Image and mask have different spacing. Using image spacing for output.")
87
+ if image.GetDirection() != mask.GetDirection():
88
+ self._logger.warning("Image and mask have different direction. Using image direction for output.")
89
+ if image.GetOrigin() != mask.GetOrigin():
90
+ self._logger.warning("Image and mask have different origin. Using image origin for output.")
91
+
92
+ # Cache geometry
93
+ self._image_spacing = image.GetSpacing() # [sx , sy , sz]
94
+ self._image_origin = image.GetOrigin() # [ox , oy , oz]
95
+ self._image_direction = image.GetDirection() # 3x3 flattened
96
+
97
+
98
+ # Determine orientation for the specified dimension
99
+ orientation = sitk.DICOMOrientImageFilter_GetOrientationFromDirectionCosines(image.GetDirection())
100
+ # Map dimension to orientation string index (dimension 0->z->2, 1->y->1, 2->x->0)
101
+ orient_idx = 2 - self.dimension
102
+ axis_orientation = orientation[orient_idx]
103
+
104
+ # Determine if Superior is at high or low index
105
+ # For anatomy: S (Superior) should be at high index in standard orientation
106
+ # If axis is I (Inferior), then Superior is at low index (reversed)
107
+ self._is_reversed = axis_orientation == 'I' if self.dimension == 0 else \
108
+ axis_orientation == 'A' if self.dimension == 1 else \
109
+ axis_orientation == 'L'
110
+
111
+ self._logger.info(f"Orientation: {orientation}, Dimension {self.dimension} axis: {axis_orientation}, Reversed: {self._is_reversed}")
112
+
113
+ mask_array = sitk.GetArrayFromImage(mask) # numpy order [z, y, x]
114
+ if mask_array.ndim != 3:
115
+ raise ValueError("Mask must be 3D.")
116
+ if np.count_nonzero(mask_array) == 0:
117
+ raise ValueError("Mask is empty (all zeros).")
118
+
119
+ slice_areas = self._calculate_slice_areas(mask_array)
120
+ if slice_areas.size == 0 or np.all(slice_areas == 0):
121
+ raise ValueError("All slice areas are zero; cannot determine cropping slice.")
122
+ self._logger.info(f"Slice areas: {slice_areas}")
123
+
124
+ min_slice_idx = self._find_minimum_area_slice(slice_areas)
125
+ if min_slice_idx is None:
126
+ raise ValueError("No valid slice found with sufficient area. Check mask quality or thresholds.")
127
+
128
+ crop_bounds = self._get_cropping_bounds(mask_array, min_slice_idx)
129
+ cropped_image = self._crop_image(image, crop_bounds)
130
+
131
+ self._logger.info(f"Cropped image at slice {min_slice_idx} with bounds: {crop_bounds}. Original {size_before} -> New {cropped_image.GetSize()}")
132
+ return cropped_image
133
+
134
+ def _calculate_slice_areas(self, mask_array: np.ndarray) -> np.ndarray:
135
+ """
136
+ Calculate physical area (mm^2) per slice along given dimension.
137
+ numpy array is [z, y, x]; dimension: 0=z, 1=y, 2=x
138
+ Pixel size is need to handle anisotropic situation
139
+ """
140
+ # Spacing in SITK is [sx, sy, sz]; array dims are [z, y, x]
141
+ sx, sy, sz = self._image_spacing
142
+
143
+ # Pixel area is the product of the spacings in the two axes orthogonal to the slicing axis
144
+ if self.dimension == 0: # slicing along z => area in y-x plane
145
+ pixel_area = sy * sx
146
+ elif self.dimension == 1: # slicing along y => area in z-x plane
147
+ pixel_area = sz * sx
148
+ else: # slicing along x => area in z-y plane
149
+ pixel_area = sz * sy
150
+
151
+ num_slices = mask_array.shape[self.dimension]
152
+ areas = np.zeros(num_slices, dtype=np.float64)
153
+
154
+ for i in range(num_slices):
155
+ if self.dimension == 0:
156
+ slice_data = mask_array[i, :, :] # [y, x]
157
+ elif self.dimension == 1:
158
+ slice_data = mask_array[:, i, :] # [z, x]
159
+ else: # self.dimension == 2
160
+ slice_data = mask_array[:, :, i] # [z, y]
161
+ areas[i] = np.count_nonzero(slice_data) * pixel_area
162
+
163
+ # Replace 0 with max value to ignore empty slices
164
+ max_area = areas.max() if areas.max() > 0 else 1.0
165
+ areas[areas == 0] = max_area
166
+ self._logger.debug(f"Calculated areas: {areas}")
167
+ return areas
168
+
169
+ def _find_minimum_area_slice(self, slice_areas: np.ndarray) -> Optional[int]:
170
+ """
171
+ Apply threshold and barrier to find minimal area slice index.
172
+ Barrier is applied from the Superior end (where shoulders typically are).
173
+ """
174
+ if slice_areas.size == 0:
175
+ return None
176
+
177
+ max_area = float(np.max(slice_areas))
178
+ if max_area <= 0.0:
179
+ self._logger.warning("Maximum slice area is zero.")
180
+ return None
181
+
182
+ min_threshold = max_area * self.min_area_threshold
183
+
184
+ # Determine Superior end based on orientation
185
+ n = len(slice_areas)
186
+ if self.barrier >= n:
187
+ self._logger.warning(f"Barrier ({self.barrier}) >= number of slices ({n}), no valid indices.")
188
+ return None
189
+
190
+ # Superior (where head are) determination:
191
+ # If NOT reversed: Superior is at high index (end of array), crop from low index
192
+ # If reversed: Superior is at low index (start of array), crop from high index
193
+ if not self._is_reversed:
194
+ # S -> Superior at high index, apply barrier from end
195
+ superior_barrier_idx = n - int(self.barrier)
196
+ candidate_indices = np.arange(0, superior_barrier_idx)
197
+ else:
198
+ # I -> Superior at low index, apply barrier from start
199
+ superior_barrier_idx = int(self.barrier)
200
+ candidate_indices = np.arange(superior_barrier_idx, n)
201
+
202
+ if len(candidate_indices) == 0:
203
+ self._logger.warning("No candidate indices after applying barrier.")
204
+ return None
205
+
206
+ valid_mask = slice_areas[candidate_indices] >= min_threshold
207
+ if not np.any(valid_mask):
208
+ self._logger.warning("No slices meet the minimum area threshold after applying barrier.")
209
+ return None
210
+
211
+ valid_indices = candidate_indices[valid_mask]
212
+ min_idx_rel = int(np.argmin(slice_areas[valid_indices]))
213
+ min_slice_idx = int(valid_indices[min_idx_rel])
214
+
215
+ self._logger.info(
216
+ f"Found minimum area slice at index {min_slice_idx} "
217
+ f"(area={slice_areas[min_slice_idx]:.3f}, max={max_area:.3f}, barrier={self.barrier}, reversed={self._is_reversed})"
218
+ )
219
+ return min_slice_idx
220
+
221
+ def _get_cropping_bounds(self, mask_array: np.ndarray, min_slice_idx: int) -> Tuple[list, list]:
222
+ """
223
+ Build crop bounds as (lower_crop, upper_crop) for SimpleITK.Crop().
224
+ Crops from Superior (shoulder side) to the minimum area slice.
225
+
226
+ Returns:
227
+ Tuple of (lower_crop, upper_crop) where each is a list [x, y, z] of integers.
228
+ """
229
+ # Validate the selected slice has mask content
230
+ if self.dimension == 0:
231
+ min_slice = mask_array[min_slice_idx, :, :]
232
+ elif self.dimension == 1:
233
+ min_slice = mask_array[:, min_slice_idx, :]
234
+ else:
235
+ min_slice = mask_array[:, :, min_slice_idx]
236
+
237
+ if np.count_nonzero(min_slice) == 0:
238
+ raise ValueError(f"No mask pixels found in slice {min_slice_idx}")
239
+
240
+ # Calculate crop bounds in SimpleITK order [x, y, z]
241
+ # mask_array.shape is [z, y, x] in numpy order
242
+ n_slices = mask_array.shape[self.dimension]
243
+
244
+ # Determine which end to crop from based on orientation
245
+ if not self._is_reversed:
246
+ # S -> Superior at high index: crop from low index up to min_slice_idx
247
+ # Keep slices [min_slice_idx, end], remove [0, min_slice_idx-1]
248
+ crop_from_start = min_slice_idx
249
+ crop_from_end = 0
250
+ else:
251
+ # I -> Superior at low index: crop from high (inferior) index down to min_slice_idx
252
+ # Keep slices [0, min_slice_idx], remove [min_slice_idx+1, end]
253
+ crop_from_end = n_slices - min_slice_idx - 1
254
+ crop_from_start = 0
255
+
256
+ # Build crop parameters in SimpleITK order [x, y, z]
257
+ if self.dimension == 0: # z-axis
258
+ lower_crop = [0, 0, crop_from_start]
259
+ upper_crop = [0, 0, crop_from_end]
260
+ elif self.dimension == 1: # y-axis
261
+ lower_crop = [0, crop_from_start, 0]
262
+ upper_crop = [0, crop_from_end, 0]
263
+ else: # x-axis
264
+ lower_crop = [crop_from_start, 0, 0]
265
+ upper_crop = [crop_from_end, 0, 0]
266
+
267
+ self._logger.debug(f"Cropping bounds: lower={lower_crop}, upper={upper_crop} (keeping slice {min_slice_idx})")
268
+ return (lower_crop, upper_crop)
269
+
270
+ def _crop_image(self, image: sitk.Image, crop_bounds: Tuple[list, list]) -> sitk.Image:
271
+ """
272
+ Crop using SimpleITK Crop with lower and upper crop parameters.
273
+ """
274
+ lower_crop, upper_crop = crop_bounds
275
+ cropped_img = sitk.Crop(image, lower_crop, upper_crop)
276
+ return cropped_img
@@ -0,0 +1,49 @@
1
+ import SimpleITK as sitk
2
+ import numpy as np
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Union, Tuple, Optional
6
+ from ..mnts_filters import MNTSFilter
7
+
8
+
9
+ __all__ = ['ReorientFilter']
10
+
11
+ class ReorientFilter(MNTSFilter):
12
+ r"""
13
+ A wrapper for sitk.DICOMOrient
14
+
15
+ Attributes:
16
+ target_orientation (str):
17
+ A three character string representing orientation code. E.g., 'LPS'.
18
+ """
19
+ def __init__(self, target_orientation: str = 'RAI'):
20
+ super(ReorientFilter, self).__init__()
21
+ self.target_orientation = target_orientation
22
+
23
+ @property
24
+ def target_orientation(self):
25
+ return self._target_orientation
26
+
27
+ @target_orientation.setter
28
+ def target_orientation(self, val: str):
29
+ assert isinstance(val, str), "Input must be a string of three characters"
30
+ assert re.fullmatch(r"(?i)[railps]{3}", val) is not None, \
31
+ f"Input must be a three-character orientation code using R/L, A/P, I/S, got '{val}'"
32
+ self._target_orientation = val.upper()
33
+
34
+ def filter(self,
35
+ image: Union[str, sitk.Image],
36
+ mask: Union[str, sitk.Image] = None):
37
+ """
38
+ Apply DICOMOrient filter to both inputs
39
+ """
40
+ image = self.read_image(image)
41
+ mask = self.read_image(mask) if mask is not None else None
42
+
43
+ ori_filter = sitk.DICOMOrientImageFilter()
44
+ ori_filter.SetDesiredCoordinateOrientation(self._target_orientation)
45
+
46
+ if mask is not None:
47
+ return (ori_filter.Execute(image), ori_filter.Execute(mask))
48
+ else:
49
+ return ori_filter.Execute(image)
@@ -0,0 +1,89 @@
1
+ import SimpleITK as sitk
2
+ import numpy as np
3
+ from pathlib import Path
4
+ from typing import Union, Tuple
5
+ from ..mnts_filters import MNTSFilter
6
+
7
+
8
+ __all__ = ['SpatialNorm']
9
+
10
+ class SpatialNorm(MNTSFilter):
11
+ r"""
12
+ This class utilize the SimpleITK filter `ResampleImageFilter` to change the spacing. All other factors remains
13
+ unchanged. However, note that the floating point rounding might results in slightly different image dimension, so
14
+ this filter should be used with the cropping filter if you require uniform data size.
15
+
16
+ Attributes:
17
+ out_spacing (float, tuple of floats):
18
+ Desired uniform spacing. Unit is mm. Use 0 or negative values if spacing is to be kept
19
+
20
+ """
21
+ def __init__(self,
22
+ out_spacing: Union[float, Tuple[float, float, float]] = None,
23
+ interpolation_method: str = 'linear'):
24
+ super(SpatialNorm, self).__init__()
25
+ self.out_spacing = out_spacing
26
+ self._interpolation_names = {
27
+ 'linear': sitk.sitkLinear,
28
+ 'bspline': sitk.sitkBSpline,
29
+ 'nearest': sitk.sitkNearestNeighbor,
30
+ }
31
+ self._interpolation = self._interpolation_names[interpolation_method]
32
+
33
+
34
+ @property
35
+ def out_spacing(self):
36
+ return self._out_spacing
37
+
38
+ @out_spacing.setter
39
+ def out_spacing(self, out_spacing: Union[float, Tuple[float, float, float]]):
40
+ self._out_spacing = out_spacing if isinstance(out_spacing, (list, tuple)) else [out_spacing]*3
41
+
42
+ @property
43
+ def interpolation(self):
44
+ return list(self._interpolation_names.keys())[
45
+ list(self._interpolation_names.values()).index(self._interpolation)
46
+ ]
47
+
48
+ @interpolation.setter
49
+ def interpolation(self, val):
50
+ if isinstance(val, str):
51
+ self._interpolation = self._interpolation_names.get(val, 'linear')
52
+ else:
53
+ assert val in self._interpolation_names.values(), f"{val} is not in available methods " \
54
+ f"{self._interpolation_names}"
55
+ self._interpolation = val
56
+
57
+ if not self._interpolation in self._interpolation_names.values():
58
+ raise IndexError(f"Incorrect interpolation scheme specified, possible options are:"
59
+ f"{list(self._interpolation_names.keys())}, , got '{val}' instead.")
60
+
61
+
62
+ def filter(self,
63
+ input: Union[str, Path, sitk.Image]
64
+ ):
65
+ input = self.read_image(input)
66
+
67
+ original_size = np.asarray(input.GetSize())
68
+ original_spacing = np.asarray(input.GetSpacing())
69
+
70
+ # Keep spacing if there's a negative value in out_spacing
71
+ new_spacing = np.asarray(self.out_spacing, dtype='float')
72
+ new_spacing[new_spacing <= 0] = original_spacing[new_spacing <=0]
73
+ new_size = np.round((original_size * original_spacing) / new_spacing).astype('int').tolist()
74
+ self._logger.info(f"\n"
75
+ f"\tSiz \t{original_size} \t-> {new_size}\n"
76
+ f"\tSpc \t{original_spacing} \t-> {new_spacing}")
77
+
78
+ # For segmentation, force to use nearest neighbor to avoid funny results.
79
+ if input.GetPixelID() == sitk.sitkUInt8:
80
+ self._interpolation = sitk.sitkNearestNeighbor
81
+
82
+ f = sitk.ResampleImageFilter()
83
+ f.SetReferenceImage(input)
84
+ f.SetOutputSpacing(new_spacing.tolist())
85
+ f.SetSize(new_size)
86
+ f.SetInterpolator(self._interpolation)
87
+ out = f.Execute(input)
88
+ return out
89
+
@@ -0,0 +1,7 @@
1
+ from .linear_rescale import *
2
+ from .linear_z_score import *
3
+ from .range_rescale import *
4
+ from .masking import *
5
+ from .hist_piecewise import *
6
+ from .n4_bias_field_correction import *
7
+ from .si_rebinning import *