From 6a495965dd0aaf27802469ccddd755b7f0f7419d Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Mon, 26 Sep 2022 15:01:35 +0100 Subject: [PATCH 1/2] fixes #3844 Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 28 +++++++++++++++++++++++- monai/transforms/spatial/dictionary.py | 10 ++++++++- tests/test_spacing.py | 30 ++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 092fa7ca27..33912e37d8 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -15,6 +15,7 @@ import warnings from copy import deepcopy from enum import Enum +from itertools import zip_longest from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import numpy as np @@ -24,7 +25,7 @@ from monai.config.type_definitions import NdarrayOrTensor from monai.data.meta_obj import get_track_meta from monai.data.meta_tensor import MetaTensor -from monai.data.utils import AFFINE_TOL, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine +from monai.data.utils import AFFINE_TOL, affine_to_spacing, compute_shape_offset, iter_patch, to_affine_nd, zoom_affine from monai.networks.layers import AffineTransform, GaussianFilter, grid_pull from monai.networks.utils import meshgrid_ij, normalize_transform from monai.transforms.croppad.array import CenterSpatialCrop, ResizeWithPadOrCrop @@ -437,6 +438,8 @@ def __init__( dtype: DtypeLike = np.float64, scale_extent: bool = False, recompute_affine: bool = False, + min_pixdim: Union[Sequence[float], float, np.ndarray, None] = None, + max_pixdim: Union[Sequence[float], float, np.ndarray, None] = None, image_only: bool = False, ) -> None: """ @@ -483,13 +486,23 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. + min_pixdim: minimally allowed input spacing. If provided, input image with a larger spacing than this value + will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. + max_pixdim: maximally allowed input spacing. If provided, input image with a smaller spacing than this value + will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. """ self.pixdim = np.array(ensure_tuple(pixdim), dtype=np.float64) + self.min_pixdim = np.array(ensure_tuple(min_pixdim), dtype=np.float64) + self.max_pixdim = np.array(ensure_tuple(max_pixdim), dtype=np.float64) self.diagonal = diagonal self.scale_extent = scale_extent self.recompute_affine = recompute_affine + for mn, mx in zip(self.min_pixdim, self.max_pixdim): + if (not np.isnan(mn)) and (not np.isnan(mx)) and (mx < mn): + raise ValueError(f"min_pixdim {self.min_pixdim} must be smaller than the max {self.max_pixdim}.") + self.sp_resample = SpatialResample( mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype ) @@ -560,6 +573,19 @@ def __call__( out_d = self.pixdim[:sr] if out_d.size < sr: out_d = np.append(out_d, [1.0] * (sr - out_d.size)) + orig_d = affine_to_spacing(affine_, sr, out_d.dtype) + for idx, (_d, mn, mx) in enumerate( + zip_longest(orig_d, self.min_pixdim[:sr], self.max_pixdim[:sr], fillvalue=np.nan) + ): + target = out_d[idx] + if np.isnan(mn) and np.isnan(mx): + continue + elif not np.isnan(mn) and np.isnan(mx): + out_d[idx] = _d if _d > mn else target + elif not np.isnan(mx) and np.isnan(mn): + out_d[idx] = _d if _d < mx else target + else: + out_d[idx] = _d if mn < _d < mx else target if not align_corners and scale_extent: warnings.warn("align_corners=False is not compatible with scale_extent=True.") diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 19a065ab8b..d5ea4564c6 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -335,6 +335,8 @@ def __init__( recompute_affine: bool = False, meta_keys: Optional[KeysCollection] = None, meta_key_postfix: str = "meta_dict", + min_pixdim: Union[Sequence[float], float, None] = None, + max_pixdim: Union[Sequence[float], float, None] = None, allow_missing_keys: bool = False, ) -> None: """ @@ -386,11 +388,17 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. + min_pixdim: minimally allowed input spacing. If provided, input image with a larger spacing than this value + will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. + max_pixdim: maximally allowed input spacing. If provided, input image with a smaller spacing than this value + will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. allow_missing_keys: don't raise exception if key is missing. """ super().__init__(keys, allow_missing_keys) - self.spacing_transform = Spacing(pixdim, diagonal=diagonal, recompute_affine=recompute_affine) + self.spacing_transform = Spacing( + pixdim, diagonal=diagonal, recompute_affine=recompute_affine, min_pixdim=min_pixdim, max_pixdim=max_pixdim + ) self.mode = ensure_tuple_rep(mode, len(self.keys)) self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys)) self.align_corners = ensure_tuple_rep(align_corners, len(self.keys)) diff --git a/tests/test_spacing.py b/tests/test_spacing.py index ff3b04f25b..e3d464e26a 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -19,7 +19,7 @@ from monai.data.meta_tensor import MetaTensor from monai.data.utils import affine_to_spacing from monai.transforms import Spacing -from monai.utils import ensure_tuple, fall_back_tuple +from monai.utils import fall_back_tuple from tests.utils import TEST_DEVICES, TEST_NDARRAYS_ALL, assert_allclose TESTS = [] @@ -245,7 +245,6 @@ def test_spacing(self, init_param, img, affine, data_param, expected_output, dev sr = min(len(res.shape) - 1, 3) if isinstance(init_param["pixdim"], float): init_param["pixdim"] = [init_param["pixdim"]] * sr - init_pixdim = ensure_tuple(init_param["pixdim"]) init_pixdim = init_param["pixdim"][:sr] norm = affine_to_spacing(res.affine, sr).cpu().numpy() assert_allclose(fall_back_tuple(init_pixdim, norm), norm, type_test=False) @@ -287,6 +286,33 @@ def test_inverse(self, device, recompute, align, scale_extent): l2_norm_affine = ((affine - img.affine) ** 2).sum() ** 0.5 self.assertLess(l2_norm_affine, 5e-2) + @parameterized.expand(TEST_INVERSE) + def test_inverse_mn_mx(self, device, recompute, align, scale_extent): + img_t = torch.rand((1, 10, 9, 8), dtype=torch.float32, device=device) + affine = torch.tensor( + [[0, 0, -1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=torch.float32, device="cpu" + ) + img = MetaTensor(img_t, affine=affine, meta={"fname": "somewhere"}) + choices = [(None, None), [1.2, None], [None, 0.7], [0.7, 0.9]] + idx = np.random.choice(range(len(choices)), size=1)[0] + tr = Spacing( + pixdim=[1.1, 1.2, 0.9], + recompute_affine=recompute, + align_corners=align, + scale_extent=scale_extent, + min_pixdim=[0.9, None, choices[idx][0]], + max_pixdim=[1.1, 1.1, choices[idx][1]], + ) + img_out = tr(img) + if isinstance(img_out, MetaTensor): + assert_allclose( + img_out.pixdim, [1.0, 1.0, 0.888889] if recompute else [1.0, 1.0, 0.9], type_test=False, rtol=1e-4 + ) + img_out = tr.inverse(img_out) + self.assertEqual(img_out.applied_operations, []) + self.assertEqual(img_out.shape, img_t.shape) + self.assertLess(((affine - img_out.affine) ** 2).sum() ** 0.5, 5e-2) + if __name__ == "__main__": unittest.main() From 497fd81baed763eea481ab8ae8295a69cbd5d076 Mon Sep 17 00:00:00 2001 From: Wenqi Li Date: Tue, 27 Sep 2022 11:42:06 +0100 Subject: [PATCH 2/2] update based on comments Signed-off-by: Wenqi Li --- monai/transforms/spatial/array.py | 27 +++++++++++++------------- monai/transforms/spatial/dictionary.py | 10 ++++++---- tests/test_spacing.py | 2 +- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 227feb9cf9..9ea7c4cddf 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -486,10 +486,12 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. - min_pixdim: minimally allowed input spacing. If provided, input image with a larger spacing than this value - will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. - max_pixdim: maximally allowed input spacing. If provided, input image with a smaller spacing than this value - will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. + min_pixdim: minimal input spacing to be resampled. If provided, input image with a larger spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. + max_pixdim: maximal input spacing to be resampled. If provided, input image with a smaller spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. """ self.pixdim = np.array(ensure_tuple(pixdim), dtype=np.float64) @@ -500,8 +502,8 @@ def __init__( self.recompute_affine = recompute_affine for mn, mx in zip(self.min_pixdim, self.max_pixdim): - if (not np.isnan(mn)) and (not np.isnan(mx)) and (mx < mn): - raise ValueError(f"min_pixdim {self.min_pixdim} must be smaller than the max {self.max_pixdim}.") + if (not np.isnan(mn)) and (not np.isnan(mx)) and ((mx < mn) or (mn < 0)): + raise ValueError(f"min_pixdim {self.min_pixdim} must be positive, smaller than max {self.max_pixdim}.") self.sp_resample = SpatialResample( mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype @@ -578,14 +580,11 @@ def __call__( zip_longest(orig_d, self.min_pixdim[:sr], self.max_pixdim[:sr], fillvalue=np.nan) ): target = out_d[idx] - if np.isnan(mn) and np.isnan(mx): - continue - elif not np.isnan(mn) and np.isnan(mx): - out_d[idx] = _d if _d > mn else target - elif not np.isnan(mx) and np.isnan(mn): - out_d[idx] = _d if _d < mx else target - else: - out_d[idx] = _d if mn < _d < mx else target + mn = target if np.isnan(mn) else min(mn, target) + mx = target if np.isnan(mx) else max(mx, target) + if mn > mx: + raise ValueError(f"min_pixdim is larger than max_pixdim at dim {idx}: min {mn} max {mx} out {target}.") + out_d[idx] = _d if (mn - AFFINE_TOL) <= _d <= (mx + AFFINE_TOL) else target if not align_corners and scale_extent: warnings.warn("align_corners=False is not compatible with scale_extent=True.") diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 3b5e032d65..3ee26c8525 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -388,10 +388,12 @@ def __init__( recompute_affine: whether to recompute affine based on the output shape. The affine computed analytically does not reflect the potential quantization errors in terms of the output shape. Set this flag to True to recompute the output affine based on the actual pixdim. Default to ``False``. - min_pixdim: minimally allowed input spacing. If provided, input image with a larger spacing than this value - will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. - max_pixdim: maximally allowed input spacing. If provided, input image with a smaller spacing than this value - will be kept the same (not be resampled to `pixdim`) by this transform, default to `None`. + min_pixdim: minimal input spacing to be resampled. If provided, input image with a larger spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. + max_pixdim: maximal input spacing to be resampled. If provided, input image with a smaller spacing than this + value will be kept in its original spacing (not be resampled to `pixdim`). Set it to `None` to use the + value of `pixdim`. Default to `None`. allow_missing_keys: don't raise exception if key is missing. """ diff --git a/tests/test_spacing.py b/tests/test_spacing.py index e3d464e26a..d9f8168883 100644 --- a/tests/test_spacing.py +++ b/tests/test_spacing.py @@ -306,7 +306,7 @@ def test_inverse_mn_mx(self, device, recompute, align, scale_extent): img_out = tr(img) if isinstance(img_out, MetaTensor): assert_allclose( - img_out.pixdim, [1.0, 1.0, 0.888889] if recompute else [1.0, 1.0, 0.9], type_test=False, rtol=1e-4 + img_out.pixdim, [1.0, 1.125, 0.888889] if recompute else [1.0, 1.2, 0.9], type_test=False, rtol=1e-4 ) img_out = tr.inverse(img_out) self.assertEqual(img_out.applied_operations, [])