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.
- mnts/__init__.py +9 -0
- mnts/filters/__init__.py +9 -0
- mnts/filters/data_node.py +119 -0
- mnts/filters/geom/__init__.py +3 -0
- mnts/filters/geom/geom_mask_crop.py +276 -0
- mnts/filters/geom/reorient_filter.py +49 -0
- mnts/filters/geom/spatial_norm.py +89 -0
- mnts/filters/intensity/__init__.py +7 -0
- mnts/filters/intensity/hist_piecewise.py +498 -0
- mnts/filters/intensity/in_wrapper.py +19 -0
- mnts/filters/intensity/intensity_base.py +52 -0
- mnts/filters/intensity/linear_rescale.py +75 -0
- mnts/filters/intensity/linear_z_score.py +45 -0
- mnts/filters/intensity/masking.py +167 -0
- mnts/filters/intensity/n4_bias_field_correction.py +97 -0
- mnts/filters/intensity/range_rescale.py +87 -0
- mnts/filters/intensity/si_rebinning.py +65 -0
- mnts/filters/mnts_filters.py +112 -0
- mnts/filters/mnts_filters_graph.py +737 -0
- mnts/filters/mpi_wrapper.py +37 -0
- mnts/io/__init__.py +1 -0
- mnts/io/data_formatting.py +737 -0
- mnts/io/dixon.py +93 -0
- mnts/mnts_logger.py +688 -0
- mnts/scripts/__init__.py +3 -0
- mnts/scripts/console_entry.py +39 -0
- mnts/scripts/dicom2nii.py +140 -0
- mnts/scripts/dicom_anon.py +10 -0
- mnts/scripts/dicom_tag_printer.py +377 -0
- mnts/scripts/normalization.py +188 -0
- mnts/scripts/organize_nifti.py +162 -0
- mnts/utils/__init__.py +4 -0
- mnts/utils/dcm_anonymize.py +125 -0
- mnts/utils/dicom_tag_printer.py +1532 -0
- mnts/utils/filename_globber.py +245 -0
- mnts/utils/histogram_analysis.py +149 -0
- mnts/utils/preprocessing.py +194 -0
- mnts/utils/sequence_check.py +209 -0
- mnts/utils/utils.py +184 -0
- mri_normalization_tools-0.4.1.dist-info/METADATA +372 -0
- mri_normalization_tools-0.4.1.dist-info/RECORD +45 -0
- mri_normalization_tools-0.4.1.dist-info/WHEEL +5 -0
- mri_normalization_tools-0.4.1.dist-info/entry_points.txt +6 -0
- mri_normalization_tools-0.4.1.dist-info/licenses/LICENSE +21 -0
- mri_normalization_tools-0.4.1.dist-info/top_level.txt +1 -0
mnts/__init__.py
ADDED
mnts/filters/__init__.py
ADDED
|
@@ -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,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
|
+
|