From ffa19c60d9584366f0248e2d7d89ef09ff9bf36d Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Thu, 13 Apr 2023 10:42:14 +0100 Subject: [PATCH 01/24] Added argument return_list to DynUNet and added unit test --- monai/networks/nets/dynunet.py | 12 +++++++-- tests/test_dynunet.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index 59e1e4a758..9b6ff0da07 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -111,7 +111,7 @@ class DynUNet(nn.Module): deep_supervision: whether to add deep supervision head before output. Defaults to ``False``. If ``True``, in training mode, the forward function will output not only the final feature map (from `output_block`), but also the feature maps that come from the intermediate up sample layers. - In order to unify the return type (the restriction of TorchScript), all intermediate + Unless return_list=True: In order to unify the return type (the restriction of TorchScript), all intermediate feature maps are interpolated into the same size as the final feature map and stacked together (with a new dimension in the first axis)into one single tensor. For instance, if there are two intermediate feature maps with shapes: (1, 2, 16, 12) and @@ -125,6 +125,9 @@ class DynUNet(nn.Module): res_block: whether to use residual connection based convolution blocks during the network. Defaults to ``False``. trans_bias: whether to set the bias parameter in transposed convolution layers. Defaults to ``False``. + return_list: return a list of the outputs of the top level output and the supervision heads rather than + a single stacked tensor of interpolated outputs. This is done because the interpolated outputs are too large + to be stored in memory. On the downside, torchscript will not work since it requires a tensor as output. """ def __init__( @@ -143,6 +146,7 @@ def __init__( deep_supr_num: int = 1, res_block: bool = False, trans_bias: bool = False, + return_list: bool = False, ): super().__init__() self.spatial_dims = spatial_dims @@ -156,6 +160,7 @@ def __init__( self.dropout = dropout self.conv_block = UnetResBlock if res_block else UnetBasicBlock self.trans_bias = trans_bias + self.return_list = return_list if filters is not None: self.filters = filters self.check_filters() @@ -268,11 +273,14 @@ def check_filters(self): def forward(self, x): out = self.skip_layers(x) out = self.output_block(out) - if self.training and self.deep_supervision: + if self.training and self.deep_supervision and not self.return_list: out_all = [out] for feature_map in self.heads: out_all.append(interpolate(feature_map, out.shape[2:])) return torch.stack(out_all, dim=1) + elif self.training and self.deep_supervision and self.return_list: + out_all = [out] + self.heads + return out_all return out def get_input_block(self): diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index 247da14b7d..8f9e944263 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -107,6 +107,41 @@ ] TEST_CASE_DEEP_SUPERVISION.append(test_case) +TEST_CASE_RETURN_LIST = [] +for spatial_dims in [2, 3]: + for deep_supr_num in [1, 2]: + for strides in [(1, 2, 1, 2, 1), (2, 2, 2, 1), (2, 1, 1, 2, 2)]: + input_shape = (1, 1, *[in_size] * spatial_dims) + + expected_shapes = [] + for deep_supr_idx, s in enumerate(strides): + if deep_supr_idx <= deep_supr_num: + if not expected_shapes: + prev_shape = (1, 2, *input_shape[2:]) + else: + prev_shape = expected_shapes[-1] + + expected_shape = (*prev_shape[:2], *[p//s for p in prev_shape[2:]]) + expected_shapes.append(expected_shape) + + test_case = [ + { + "spatial_dims": spatial_dims, + "in_channels": 1, + "out_channels": 2, + "kernel_size": [3] * len(strides), + "strides": strides, + "upsample_kernel_size": strides[1:], + "norm_name": ("group", {"num_groups": 16}), + "deep_supervision": True, + "deep_supr_num": deep_supr_num, + "return_list": True, + }, + input_shape, + expected_shapes, + ] + TEST_CASE_RETURN_LIST.append(test_case) + class TestDynUNet(unittest.TestCase): @parameterized.expand(TEST_CASE_DYNUNET_3D) @@ -169,5 +204,16 @@ def test_shape(self, input_param, input_shape, expected_shape): self.assertEqual(results.shape, expected_shape) +class TestDynUNetReturnList(unittest.TestCase): + @parameterized.expand(TEST_CASE_RETURN_LIST) + def test_shape(self, input_param, input_shape, expected_shapes): + net = DynUNet(**input_param).to(device) + with torch.no_grad(): + results = net(torch.randn(input_shape).to(device)) + self.assertTrue(len(results) == len(expected_shapes), f"Expected output of length {len(expected_shapes)}, but got {len(results)}.") + for res, expected_shape in zip(results, expected_shapes): + self.assertEqual(res.shape, expected_shape) + + if __name__ == "__main__": unittest.main() From d3907c3e447a56b902a020ea847937ff05c66152 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Thu, 13 Apr 2023 15:52:21 +0100 Subject: [PATCH 02/24] Added AppendDownsampled array and dictionary transform and unit tests. --- monai/transforms/__init__.py | 4 ++ monai/transforms/post/array.py | 58 ++++++++++++++++++++++++++++- monai/transforms/post/dictionary.py | 27 +++++++++++++- tests/test_append_downsampled.py | 49 ++++++++++++++++++++++++ tests/test_append_downsampledd.py | 51 +++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 tests/test_append_downsampled.py create mode 100644 tests/test_append_downsampledd.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 11790e639b..8f98a6e205 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -268,6 +268,7 @@ ) from .post.array import ( Activations, + AppendDownsampled, AsDiscrete, FillHoles, Invert, @@ -282,6 +283,9 @@ ) from .post.dictionary import ( ActivationsD, + AppendDownsampledd, + AppendDownsampledD, + AppendDownsampledDict, Activationsd, ActivationsDict, AsDiscreteD, diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 8e0c642d8b..91ef6aa1bc 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -29,7 +29,7 @@ from monai.networks.layers import GaussianFilter, apply_filter, separable_filtering from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Transform -from monai.transforms.utility.array import ToTensor +from monai.transforms.utility.array import ToTensor, CastToType, EnsureType from monai.transforms.utils import ( convert_applied_interp_mode, fill_holes, @@ -40,9 +40,11 @@ from monai.transforms.utils_pytorch_numpy_unification import unravel_index from monai.utils import TransformBackends, convert_data_type, convert_to_tensor, ensure_tuple, look_up_option from monai.utils.type_conversion import convert_to_dst_type +from torch.nn.functional import interpolate __all__ = [ "Activations", + "AppendDownsampled", "AsDiscrete", "FillHoles", "KeepLargestConnectedComponent", @@ -128,6 +130,60 @@ def __call__( return out +class AppendDownsampled(Transform): + """ + Convert the input tensor/array into a List of tensors/arrays of downsampled versions of the original tensor/array. + This is useful for deep supervision where outputs of deep supervision heads can be of lower resolution: + + - creates empty List + - appends downsampled tensor for each shape provided in downsampled_shapes + - uses torch.nn.functional.interpolate for downsampling operation + + + Args: + downsampled_shapes: List of shapes of the downsampled tensors/arrays + + """ + def __init__( + self, + downsampled_shapes, + mode='nearest', + ) -> None: + + self.downsampled_shapes = downsampled_shapes + self.mode = mode + + def __call__(self, + img: NdarrayOrTensor, + ) -> NdarrayOrTensor: + + img = convert_to_tensor(img, track_meta=get_track_meta()) + + spatial_input_size = len(self.downsampled_shapes[0]) + + # make sure input shape is B, C, W, H, (D) + added_dims = 0 + while len(img.shape) - spatial_input_size < 2: + img = img.unsqueeze(0) + added_dims += 1 + + ret = [] + for s in self.downsampled_shapes: + downsampled_img = interpolate(input=img, size=s, mode=self.mode) + + downsampled_img = CastToType(dtype=np.uint8)(downsampled_img) # TODO: restricts functions to work with uint8, check influence of removing this line + downsampled_img = EnsureType()(downsampled_img) + + ret.append(downsampled_img) + + # make sure output shape of each list entry is C, W, H, (D) + for i in range(len(ret)): + for _ in range(added_dims): + ret[i] = ret[i].squeeze(0) + + return ret + + class AsDiscrete(Transform): """ Convert the input tensor/array into discrete values, possible operations are: diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 3fbfe46118..a8c6a128a7 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -41,7 +41,7 @@ ProbNMS, RemoveSmallObjects, SobelGradients, - VoteEnsemble, + VoteEnsemble, AppendDownsampled, ) from monai.transforms.transform import MapTransform from monai.transforms.utility.array import ToTensor @@ -143,6 +143,30 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class AppendDownsampledd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.AppendDownsampled`. + Convert the input tensor/array specified by `keys` into a List of tensors/arrays of downsampled versions of the + original tensor/array. This is useful for deep supervision where outputs of deep supervision heads can be of lower + resolution. + """ + def __init__( + self, + keys: KeysCollection, + downsampled_shapes, + allow_missing_keys: bool = False, + ) -> None: + MapTransform.__init__(self, keys, allow_missing_keys) + self.append_downsampled = AppendDownsampled(downsampled_shapes) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for key in self.key_iterator(d): + d[key] = self.append_downsampled(d[key]) + return d + + class AsDiscreted(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AsDiscrete`. @@ -856,6 +880,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N ActivationsD = ActivationsDict = Activationsd +AppendDownsampledD = AppendDownsampledDict = AppendDownsampledd AsDiscreteD = AsDiscreteDict = AsDiscreted FillHolesD = FillHolesDict = FillHolesd InvertD = InvertDict = Invertd diff --git a/tests/test_append_downsampled.py b/tests/test_append_downsampled.py new file mode 100644 index 0000000000..1774a15a49 --- /dev/null +++ b/tests/test_append_downsampled.py @@ -0,0 +1,49 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import AppendDownsampled +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for val in [2, 3]: # TODO: include negative and float, currently doesn't work because of uint8 conversion + downsampled_shapes = [(5, 5, 5), (4, 4, 4)] + TEST_CASES.append( + [ + {"downsampled_shapes": downsampled_shapes}, + p(np.ones([10, 10, 9])*val), + [p(np.ones(s)*val) for s in downsampled_shapes], + downsampled_shapes, + ] + ) + + +class TestAppendDownsampled(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, img, out, expected_shapes): + result = AppendDownsampled(**input_param)(img) + + self.assertEqual(len(result), len(expected_shapes)) + + for res, o, exp_shape in zip(result, out, expected_shapes): + self.assertTupleEqual(res.shape, exp_shape) + assert_allclose(res, o, rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_append_downsampledd.py b/tests/test_append_downsampledd.py new file mode 100644 index 0000000000..e3428be680 --- /dev/null +++ b/tests/test_append_downsampledd.py @@ -0,0 +1,51 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +from parameterized import parameterized + +from monai.transforms import AppendDownsampledd +from tests.utils import TEST_NDARRAYS, assert_allclose + +import numpy as np + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for val in [2, 3]: + downsampled_shapes = [(5, 5, 5), (4, 4, 4)] + TEST_CASES.append( + [ + {"keys": ["label"], "downsampled_shapes":downsampled_shapes}, + {"pred": p(np.ones([10, 10, 9])*(val-3.2)), "label": p(np.ones([10, 10, 9])*val)}, + {"pred": p(np.ones([10, 10, 9])*(val-3.2)), "label": [p(np.ones(s)*val) for s in downsampled_shapes]}, + downsampled_shapes, + ] + ) + + +class TestAppendDownsampledd(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, test_input, output, expected_shapes): + result = AppendDownsampledd(**input_param)(test_input) + assert_allclose(result["pred"], output["pred"], rtol=1e-3) # "pred" should not have been affected by transform + if "label" in result: + self.assertEqual(len(result["label"]), len(expected_shapes)) + + for res, o, exp_shape in zip(result["label"], output["label"], expected_shapes): + self.assertTupleEqual(res.shape, exp_shape) + assert_allclose(res, o, rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() From b90db1c4ebe77f1c5908f0dfa916f00c76ac86be Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 09:27:12 +0100 Subject: [PATCH 03/24] Ran automatic code formatting --- monai/transforms/__init__.py | 4 +- monai/transforms/post/array.py | 19 +++----- monai/transforms/post/dictionary.py | 11 ++--- tests/test_append_downsampled.py | 4 +- tests/test_append_downsampledd.py | 12 ++--- tests/test_dynunet.py | 69 +++++++++++++++-------------- 6 files changed, 58 insertions(+), 61 deletions(-) diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 8f98a6e205..5c7c2fc575 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -283,11 +283,11 @@ ) from .post.dictionary import ( ActivationsD, + Activationsd, + ActivationsDict, AppendDownsampledd, AppendDownsampledD, AppendDownsampledDict, - Activationsd, - ActivationsDict, AsDiscreteD, AsDiscreted, AsDiscreteDict, diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 91ef6aa1bc..70fdfeabd5 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -21,6 +21,7 @@ import numpy as np import torch import torch.nn.functional as F +from torch.nn.functional import interpolate from monai.config.type_definitions import NdarrayOrTensor from monai.data.meta_obj import get_track_meta @@ -29,7 +30,7 @@ from monai.networks.layers import GaussianFilter, apply_filter, separable_filtering from monai.transforms.inverse import InvertibleTransform from monai.transforms.transform import Transform -from monai.transforms.utility.array import ToTensor, CastToType, EnsureType +from monai.transforms.utility.array import CastToType, EnsureType, ToTensor from monai.transforms.utils import ( convert_applied_interp_mode, fill_holes, @@ -40,7 +41,6 @@ from monai.transforms.utils_pytorch_numpy_unification import unravel_index from monai.utils import TransformBackends, convert_data_type, convert_to_tensor, ensure_tuple, look_up_option from monai.utils.type_conversion import convert_to_dst_type -from torch.nn.functional import interpolate __all__ = [ "Activations", @@ -144,19 +144,12 @@ class AppendDownsampled(Transform): downsampled_shapes: List of shapes of the downsampled tensors/arrays """ - def __init__( - self, - downsampled_shapes, - mode='nearest', - ) -> None: + def __init__(self, downsampled_shapes, mode="nearest") -> None: self.downsampled_shapes = downsampled_shapes self.mode = mode - def __call__(self, - img: NdarrayOrTensor, - ) -> NdarrayOrTensor: - + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: img = convert_to_tensor(img, track_meta=get_track_meta()) spatial_input_size = len(self.downsampled_shapes[0]) @@ -171,7 +164,9 @@ def __call__(self, for s in self.downsampled_shapes: downsampled_img = interpolate(input=img, size=s, mode=self.mode) - downsampled_img = CastToType(dtype=np.uint8)(downsampled_img) # TODO: restricts functions to work with uint8, check influence of removing this line + downsampled_img = CastToType(dtype=np.uint8)( + downsampled_img + ) # TODO: restricts functions to work with uint8, check influence of removing this line downsampled_img = EnsureType()(downsampled_img) ret.append(downsampled_img) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index a8c6a128a7..5ade666677 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -32,6 +32,7 @@ from monai.transforms.inverse import InvertibleTransform from monai.transforms.post.array import ( Activations, + AppendDownsampled, AsDiscrete, FillHoles, KeepLargestConnectedComponent, @@ -41,7 +42,7 @@ ProbNMS, RemoveSmallObjects, SobelGradients, - VoteEnsemble, AppendDownsampled, + VoteEnsemble, ) from monai.transforms.transform import MapTransform from monai.transforms.utility.array import ToTensor @@ -150,12 +151,8 @@ class AppendDownsampledd(MapTransform): original tensor/array. This is useful for deep supervision where outputs of deep supervision heads can be of lower resolution. """ - def __init__( - self, - keys: KeysCollection, - downsampled_shapes, - allow_missing_keys: bool = False, - ) -> None: + + def __init__(self, keys: KeysCollection, downsampled_shapes, allow_missing_keys: bool = False) -> None: MapTransform.__init__(self, keys, allow_missing_keys) self.append_downsampled = AppendDownsampled(downsampled_shapes) diff --git a/tests/test_append_downsampled.py b/tests/test_append_downsampled.py index 1774a15a49..09c7652776 100644 --- a/tests/test_append_downsampled.py +++ b/tests/test_append_downsampled.py @@ -26,8 +26,8 @@ TEST_CASES.append( [ {"downsampled_shapes": downsampled_shapes}, - p(np.ones([10, 10, 9])*val), - [p(np.ones(s)*val) for s in downsampled_shapes], + p(np.ones([10, 10, 9]) * val), + [p(np.ones(s) * val) for s in downsampled_shapes], downsampled_shapes, ] ) diff --git a/tests/test_append_downsampledd.py b/tests/test_append_downsampledd.py index e3428be680..f188ec6310 100644 --- a/tests/test_append_downsampledd.py +++ b/tests/test_append_downsampledd.py @@ -13,22 +13,24 @@ import unittest +import numpy as np from parameterized import parameterized from monai.transforms import AppendDownsampledd from tests.utils import TEST_NDARRAYS, assert_allclose -import numpy as np - TEST_CASES = [] for p in TEST_NDARRAYS: for val in [2, 3]: downsampled_shapes = [(5, 5, 5), (4, 4, 4)] TEST_CASES.append( [ - {"keys": ["label"], "downsampled_shapes":downsampled_shapes}, - {"pred": p(np.ones([10, 10, 9])*(val-3.2)), "label": p(np.ones([10, 10, 9])*val)}, - {"pred": p(np.ones([10, 10, 9])*(val-3.2)), "label": [p(np.ones(s)*val) for s in downsampled_shapes]}, + {"keys": ["label"], "downsampled_shapes": downsampled_shapes}, + {"pred": p(np.ones([10, 10, 9]) * (val - 3.2)), "label": p(np.ones([10, 10, 9]) * val)}, + { + "pred": p(np.ones([10, 10, 9]) * (val - 3.2)), + "label": [p(np.ones(s) * val) for s in downsampled_shapes], + }, downsampled_shapes, ] ) diff --git a/tests/test_dynunet.py b/tests/test_dynunet.py index 8f9e944263..98c41750bf 100644 --- a/tests/test_dynunet.py +++ b/tests/test_dynunet.py @@ -109,38 +109,38 @@ TEST_CASE_RETURN_LIST = [] for spatial_dims in [2, 3]: - for deep_supr_num in [1, 2]: - for strides in [(1, 2, 1, 2, 1), (2, 2, 2, 1), (2, 1, 1, 2, 2)]: - input_shape = (1, 1, *[in_size] * spatial_dims) - - expected_shapes = [] - for deep_supr_idx, s in enumerate(strides): - if deep_supr_idx <= deep_supr_num: - if not expected_shapes: - prev_shape = (1, 2, *input_shape[2:]) - else: - prev_shape = expected_shapes[-1] - - expected_shape = (*prev_shape[:2], *[p//s for p in prev_shape[2:]]) - expected_shapes.append(expected_shape) - - test_case = [ - { - "spatial_dims": spatial_dims, - "in_channels": 1, - "out_channels": 2, - "kernel_size": [3] * len(strides), - "strides": strides, - "upsample_kernel_size": strides[1:], - "norm_name": ("group", {"num_groups": 16}), - "deep_supervision": True, - "deep_supr_num": deep_supr_num, - "return_list": True, - }, - input_shape, - expected_shapes, - ] - TEST_CASE_RETURN_LIST.append(test_case) + for deep_supr_num in [1, 2]: + for strides in [(1, 2, 1, 2, 1), (2, 2, 2, 1), (2, 1, 1, 2, 2)]: + input_shape = (1, 1, *[in_size] * spatial_dims) + + expected_shapes = [] + for deep_supr_idx, s in enumerate(strides): + if deep_supr_idx <= deep_supr_num: + if not expected_shapes: + prev_shape = (1, 2, *input_shape[2:]) + else: + prev_shape = expected_shapes[-1] + + expected_shape = (*prev_shape[:2], *[p // s for p in prev_shape[2:]]) + expected_shapes.append(expected_shape) + + test_case = [ + { + "spatial_dims": spatial_dims, + "in_channels": 1, + "out_channels": 2, + "kernel_size": [3] * len(strides), + "strides": strides, + "upsample_kernel_size": strides[1:], + "norm_name": ("group", {"num_groups": 16}), + "deep_supervision": True, + "deep_supr_num": deep_supr_num, + "return_list": True, + }, + input_shape, + expected_shapes, + ] + TEST_CASE_RETURN_LIST.append(test_case) class TestDynUNet(unittest.TestCase): @@ -210,7 +210,10 @@ def test_shape(self, input_param, input_shape, expected_shapes): net = DynUNet(**input_param).to(device) with torch.no_grad(): results = net(torch.randn(input_shape).to(device)) - self.assertTrue(len(results) == len(expected_shapes), f"Expected output of length {len(expected_shapes)}, but got {len(results)}.") + self.assertTrue( + len(results) == len(expected_shapes), + f"Expected output of length {len(expected_shapes)}, but got {len(results)}.", + ) for res, expected_shape in zip(results, expected_shapes): self.assertEqual(res.shape, expected_shape) From 55721988ff0edfb969f98bff6a566d6a9be919be Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 10:25:35 +0100 Subject: [PATCH 04/24] Added comments to AppendDownsampledd --- monai/transforms/post/dictionary.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 5ade666677..1c038b8eb3 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -153,7 +153,16 @@ class AppendDownsampledd(MapTransform): """ def __init__(self, keys: KeysCollection, downsampled_shapes, allow_missing_keys: bool = False) -> None: - MapTransform.__init__(self, keys, allow_missing_keys) + super().__init__(keys, allow_missing_keys) + """ + Args: + keys: keys of the corresponding items. + See also: :py:class:`monai.transforms.compose.MapTransform` + downsampled_shapes: List of shapes of the downsampled tensors/arrays + allow_missing_keys: don't raise exception if key is missing. + + """ + self.append_downsampled = AppendDownsampled(downsampled_shapes) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: From 8d246e2d9655f3de458e26d16521e643b35070af Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 15:57:26 +0100 Subject: [PATCH 05/24] Added SampleForegroundLocations array and dictionary transforms with unit tests. --- monai/transforms/__init__.py | 4 ++ monai/transforms/utility/array.py | 34 +++++++++++++ monai/transforms/utility/dictionary.py | 38 ++++++++++++++- tests/test_sample_foreground_locations.py | 47 ++++++++++++++++++ tests/test_sample_foreground_locationsd.py | 56 ++++++++++++++++++++++ 5 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/test_sample_foreground_locations.py create mode 100644 tests/test_sample_foreground_locationsd.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 5c7c2fc575..e419aa80fa 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -488,6 +488,7 @@ RemoveRepeatedChannel, RepeatChannel, SimulateDelay, + SampleForegroundLocations, SplitChannel, SplitDim, SqueezeDim, @@ -587,6 +588,9 @@ RepeatChanneld, RepeatChannelD, RepeatChannelDict, + SampleForegroundLocationsd, + SampleForegroundLocationsD, + SampleForegroundLocationsDict, SelectItemsd, SelectItemsD, SelectItemsDict, diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index e982f3ced1..23359b5cc7 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -98,6 +98,7 @@ "Lambda", "RandLambda", "LabelToMask", + "SampleForegroundLocations", "FgBgToIndices", "ClassesToIndices", "ConvertToMultiChannelBasedOnBratsClasses", @@ -976,6 +977,39 @@ def __call__( return data +class SampleForegroundLocations(Transform): + """ + Sample foreground locations and store in metadata of the label. The locations can be used for foreground + oversampling. + """ + def __init__( + self, + num_samples: int = 1000, + dtype: DtypeLike = np.float32, + ) -> None: + """ + Args: + num_samples: number of foreground samples + dtype: data type of output tensor + """ + self.num_samples = num_samples + self.dtype = dtype + + def __call__(self, label: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Sample foreground locations from 'label'. + """ + label = convert_to_tensor(label, track_meta=get_track_meta(), dtype=self.dtype) + + all_locations = (label > 0).nonzero() + + # TODO: Handle case of no foreground + random_indices = torch.randint(0, len(all_locations), (self.num_samples, )) + random_samples = all_locations[random_indices] + + label.meta["foreground_sample_locations"] = random_samples.cpu().numpy() + return label + class FgBgToIndices(Transform, MultiSampleTrait): """ Compute foreground and background of the input label data, return the indices. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 40b1527443..48289e60df 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -63,7 +63,7 @@ ToPIL, TorchVision, ToTensor, - Transpose, + Transpose, SampleForegroundLocations, ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.transforms.utils_pytorch_numpy_unification import concatenate @@ -152,6 +152,9 @@ "RepeatChannelD", "RepeatChannelDict", "RepeatChanneld", + "SampleForegroundLocationsD", + "SampleForegroundLocationsDict", + "SampleForegroundLocationsd", "SelectItemsD", "SelectItemsDict", "SelectItemsd", @@ -1244,6 +1247,38 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class SampleForegroundLocationsd(MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.SampleForegroundLocations`. + """ + def __init__( + self, + label_keys: KeysCollection, + num_samples: int = 1000, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + label_keys: keys of labels from where the foreground is sampled + See also: :py:class:`monai.transforms.compose.MapTransform` + num_samples: how many samples to draw + type: data type of output tensor + allow_missing_keys: don't raise exception if key is missing. + """ + MapTransform.__init__(self, label_keys, allow_missing_keys) + + self.dtype = dtype + self.sample_foreground_locations = SampleForegroundLocations(num_samples=num_samples, dtype=self.dtype) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + + for key in self.key_iterator(d): + d[key] = self.sample_foreground_locations(d[key]) + return d + + class FgBgToIndicesd(MapTransform, MultiSampleTrait): """ Dictionary-based wrapper of :py:class:`monai.transforms.FgBgToIndices`. @@ -1851,6 +1886,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N ConcatItemsD = ConcatItemsDict = ConcatItemsd LambdaD = LambdaDict = Lambdad LabelToMaskD = LabelToMaskDict = LabelToMaskd +SampleForegroundLocationsD = SampleForegroundLocationsDict = SampleForegroundLocationsd FgBgToIndicesD = FgBgToIndicesDict = FgBgToIndicesd ClassesToIndicesD = ClassesToIndicesDict = ClassesToIndicesd ConvertToMultiChannelBasedOnBratsClassesD = ( diff --git a/tests/test_sample_foreground_locations.py b/tests/test_sample_foreground_locations.py new file mode 100644 index 0000000000..8818074c80 --- /dev/null +++ b/tests/test_sample_foreground_locations.py @@ -0,0 +1,47 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import SampleForegroundLocations +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append( + [ + {"num_samples": num_samples}, + p(np.ones([10, 10, 9])), + p(np.ones([10, 10, 9])), + num_samples, + ] + ) + + +class TestSampleForegroundLocations(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, img, out, expected_num_samples): + result = SampleForegroundLocations(**input_param)(img) + + # check if metadata entry has the expected length + self.assertEqual(len(result.meta["foreground_sample_locations"]), expected_num_samples) + + # output tensor should remain unchanged + assert_allclose(result, out, rtol=1e-3, type_test="tensor") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sample_foreground_locationsd.py b/tests/test_sample_foreground_locationsd.py new file mode 100644 index 0000000000..2dcc7ceed0 --- /dev/null +++ b/tests/test_sample_foreground_locationsd.py @@ -0,0 +1,56 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import SampleForegroundLocationsd +from tests.utils import TEST_NDARRAYS, assert_allclose + +TEST_CASES = [] +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append( + [ + {"label_keys": ["label"], "num_samples": num_samples}, + { + "image": p(np.zeros([10, 10, 9])), + "label": p(np.ones([10, 10, 9])) + }, + { + "image": p(np.zeros([10, 10, 9])), + "label": p(np.ones([10, 10, 9])) + }, + num_samples, + ] + ) + + +class TestSampleForegroundLocationsd(unittest.TestCase): + @parameterized.expand(TEST_CASES) + def test_value_shape(self, input_param, test_input, output, expected_num_samples): + result = SampleForegroundLocationsd(**input_param)(test_input) + assert_allclose(result["image"], output["image"], rtol=1e-3) # "image" should not have been affected by transform + assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") # "label" should not have been affected by transform + if "label" in result: + self.assertEqual(len(result["label"].meta["foreground_sample_locations"]), expected_num_samples) + + # output tensor should remain unchanged + #assert_allclose(result["image"], output["image"], rtol=1e-3, type_test="tensor") + #assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() From 23f3d9337b3133040854f356a55e641edc9c34e2 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 15:59:03 +0100 Subject: [PATCH 06/24] Added AppendDownsampledDict etc to __all__ --- monai/transforms/post/dictionary.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index 1c038b8eb3..ace6c6d718 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -53,6 +53,9 @@ "ActivationsD", "ActivationsDict", "Activationsd", + "AppendDownsampledD", + "AppendDownsampledDict", + "AppendDownsampledd", "AsDiscreteD", "AsDiscreteDict", "AsDiscreted", From 57158b2c564ee6e4b0dba4c60972ac793f6ec12d Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 17:53:49 +0100 Subject: [PATCH 07/24] Added arguments for separate probabilities for rotation, translation, shearing, and scaling in RandAffine and RandAffineGrid --- monai/transforms/spatial/array.py | 36 +++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index f263e89152..3a260f65db 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1611,9 +1611,13 @@ class RandAffineGrid(Randomizable, LazyTransform): def __init__( self, rotate_range: RandRange = None, + prob_rotate: float = 1, shear_range: RandRange = None, + prob_shear: float = 1, translate_range: RandRange = None, + prob_translate: float = 1, scale_range: RandRange = None, + prob_scale: float = 1, device: torch.device | None = None, dtype: DtypeLike = np.float32, ) -> None: @@ -1653,9 +1657,13 @@ def __init__( """ self.rotate_range = ensure_tuple(rotate_range) + self.prob_rotate = prob_rotate self.shear_range = ensure_tuple(shear_range) + self.prob_shear = prob_shear self.translate_range = ensure_tuple(translate_range) + self.prob_translate = prob_translate self.scale_range = ensure_tuple(scale_range) + self.prob_scale = prob_scale self.rotate_params: list[float] | None = None self.shear_params: list[float] | None = None @@ -1678,10 +1686,22 @@ def _get_rand_param(self, param_range, add_scalar: float = 0.0): return out_param def randomize(self, data: Any | None = None) -> None: - self.rotate_params = self._get_rand_param(self.rotate_range) - self.shear_params = self._get_rand_param(self.shear_range) - self.translate_params = self._get_rand_param(self.translate_range) - self.scale_params = self._get_rand_param(self.scale_range, 1.0) + if self.R.rand() < self.prob_rotate: + self.rotate_params = self._get_rand_param(self.rotate_range) + else: + self.rotate_params = None + if self.R.rand() < self.prob_shear: + self.shear_params = self._get_rand_param(self.shear_range) + else: + self.shear_params = None + if self.R.rand() < self.prob_translate: + self.translate_params = self._get_rand_param(self.translate_range) + else: + self.translate_params = None + if self.R.rand() < self.prob_scale: + self.scale_params = self._get_rand_param(self.scale_range, 1.0) + else: + self.scale_params = None def __call__( self, spatial_size: Sequence[int] | None = None, grid: NdarrayOrTensor | None = None, randomize: bool = True @@ -2144,9 +2164,13 @@ def __init__( self, prob: float = 0.1, rotate_range: RandRange = None, + prob_rotate: float = 1, shear_range: RandRange = None, + prob_shear: float = 1, translate_range: RandRange = None, + prob_translate: float = 1, scale_range: RandRange = None, + prob_scale: float = 1, spatial_size: Sequence[int] | int | None = None, mode: str | int = GridSampleMode.BILINEAR, padding_mode: str = GridSamplePadMode.REFLECTION, @@ -2211,9 +2235,13 @@ def __init__( self.rand_affine_grid = RandAffineGrid( rotate_range=rotate_range, + prob_rotate=prob_rotate, shear_range=shear_range, + prob_shear=prob_shear, translate_range=translate_range, + prob_translate=prob_translate, scale_range=scale_range, + prob_scale=prob_scale, device=device, ) self.resampler = Resample(device=device) From b79eb438ff5716eb20eeda5b9af5d3a259ad2f7b Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 14 Apr 2023 18:10:17 +0100 Subject: [PATCH 08/24] Added arguments for separate probabilities for rotation, translation, shearing, and scaling in RandAffined --- monai/transforms/spatial/dictionary.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 2f34f57ca2..d882432006 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -824,9 +824,13 @@ def __init__( spatial_size: Sequence[int] | int | None = None, prob: float = 0.1, rotate_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_rotate: float = 1, shear_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_shear: float = 1, translate_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_translate: float = 1, scale_range: Sequence[tuple[float, float] | float] | float | None = None, + prob_scale: float = 1, mode: SequenceStr = GridSampleMode.BILINEAR, padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, cache_grid: bool = False, @@ -896,9 +900,13 @@ def __init__( self.rand_affine = RandAffine( prob=1.0, # because probability handled in this class rotate_range=rotate_range, + prob_rotate=prob_rotate, shear_range=shear_range, + prob_shear=prob_shear, translate_range=translate_range, + prob_translate=prob_translate, scale_range=scale_range, + prob_scale=prob_scale, spatial_size=spatial_size, cache_grid=cache_grid, device=device, From 23ffa5cb3d965e4d01df040ace10dd046c260502 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Mon, 17 Apr 2023 13:40:38 +0100 Subject: [PATCH 09/24] Added agruments for foreground oversampling and translation within valid range to RandAffine, RandAffined, and RandAffineGrid --- monai/transforms/spatial/array.py | 116 +++++++++++++++++++++++-- monai/transforms/spatial/dictionary.py | 41 ++++++++- 2 files changed, 147 insertions(+), 10 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 3a260f65db..cfd6e04b53 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1618,17 +1618,19 @@ def __init__( prob_translate: float = 1, scale_range: RandRange = None, prob_scale: float = 1, + foreground_oversampling_prob: float = None, device: torch.device | None = None, dtype: DtypeLike = np.float32, ) -> None: """ - Args: - rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 and nothing for the remaining dimensions. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, take a 3D affine as example:: @@ -1639,12 +1641,26 @@ def __init__( [params[4], params[5], 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] - + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select voxels to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. This allows 0 to correspond to no change (i.e., a scaling of 1.0). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. device: device to store the output grid data. dtype: data type for the grid computation. Defaults to ``np.float32``. If ``None``, use the data type of input data (if `grid` is provided). @@ -1670,10 +1686,16 @@ def __init__( self.translate_params: list[float] | None = None self.scale_params: list[float] | None = None + self.foreground_oversampling_prob = foreground_oversampling_prob + self.translate_to_foreground = False + self.device = device self.dtype = dtype self.affine: torch.Tensor | None = torch.eye(4, dtype=torch.float64) + self.rand_norm_translate_params = None + self.rand_float_to_pick_fg_location = None + def _get_rand_param(self, param_range, add_scalar: float = 0.0): out_param = [] for f in param_range: @@ -1702,14 +1724,27 @@ def randomize(self, data: Any | None = None) -> None: self.scale_params = self._get_rand_param(self.scale_range, 1.0) else: self.scale_params = None + if self.foreground_oversampling_prob is not None and self.R.rand() < self.foreground_oversampling_prob: + self.translate_to_foreground = True + self.rand_float_to_pick_fg_location = self.R.rand() + else: + self.translate_to_foreground = False + self.rand_norm_translate_params = self._get_rand_param(((-1.0, 1.0), (-1.0, 1.0), (-1.0, 1.0))) def __call__( - self, spatial_size: Sequence[int] | None = None, grid: NdarrayOrTensor | None = None, randomize: bool = True + self, + spatial_size: Sequence[int] | None = None, + grid: NdarrayOrTensor | None = None, + image_size: Optional[Sequence[int]] = None, + foreground_oversampling_prob: Optional[float] = None, + fg_indices: Optional[NdarrayOrTensor] = None, + randomize: bool = True, ) -> torch.Tensor: """ Args: spatial_size: output grid size. grid: grid to be transformed. Shape must be (3, H, W) for 2D or (4, H, W, D) for 3D. + image_size: size of the image to sample from. This is required to determine the valid translate_range randomize: boolean as to whether the grid parameters governing the grid should be randomized. Returns: @@ -1717,6 +1752,53 @@ def __call__( """ if randomize: self.randomize() + + # if the foreground_oversampling_prob argument is used, the translation is determined by whether a foreground + # location should initially be targeted, furthermore, any translation will be clipped to a "valid" range + if self.foreground_oversampling_prob is not None: + + # define the margin at the image borders (where the center point is not supposed to end up) by half the patch size + patch_size = np.array(grid.shape[1:] if grid is not None else spatial_size) + if self.scale_params is not None: + patch_size = np.array(patch_size) * np.array(self.scale_params) + + margin = patch_size / 2 + max_transl = np.array(image_size) / 2 - margin + + # it is possible that the input image is smaller than the patch size + # in that case no translation should happen, so set those values to 0 + max_transl = np.array([max(tr, 0) for tr in max_transl]) + + if self.translate_to_foreground: # first translate to foreground pixel, then add the random translation, then clip to valid range + + # randomly pick one of the previously sampled foreground pixels to translate the center point of the grid to + # select one fg sample based on float randomized in randomize function + rand_int = int(np.round(self.rand_float_to_pick_fg_location*(len(fg_indices)-1))) + random_fg_index = fg_indices[rand_int][1:] + + # from this, calculate a translation to the fg point of a grid which is initially located at the image center + translate_params_to_fg_point = list(np.array(random_fg_index) - np.array(image_size) / 2) + + # add the additional random translation which was randomly selected from the translate_range + if self.translate_params is not None and self.translate_params != []: + assert(len(translate_params_to_fg_point) == len(self.translate_params)) + translate_params = [t_fg + t_range for t_fg, t_range + in zip(translate_params_to_fg_point, self.translate_params)] + else: + translate_params = translate_params_to_fg_point + + else: # simply translate randomly within valid range (in this case, original translate_params are discarded) + # for each dimension, self.rand_norm_translate_params was randomly sampled between -1 and 1, + # now scale this value to the full range + translate_params = [max_transl[i]*self.rand_norm_translate_params[i] for i in range(len(max_transl))] + + # if the current translation parameters exceed the max_transl, use max_transl in the corresponding direction instead + clipped_translate_params = [t_fg if abs(t_fg) <= t_max else t_fg / abs(t_fg) * t_max + for t_fg, t_max in zip(translate_params, max_transl)] + + self.translate_params = list(clipped_translate_params) + + # create the affine grid affine_grid = AffineGrid( rotate_params=self.rotate_params, shear_params=self.shear_params, @@ -2171,10 +2253,11 @@ def __init__( prob_translate: float = 1, scale_range: RandRange = None, prob_scale: float = 1, - spatial_size: Sequence[int] | int | None = None, - mode: str | int = GridSampleMode.BILINEAR, + spatial_size: Optional[Union[Sequence[int], int]] = None, + mode: Union[str, int] = GridSampleMode.BILINEAR, padding_mode: str = GridSamplePadMode.REFLECTION, cache_grid: bool = False, + foreground_oversampling_prob: Optional[float] = None, device: torch.device | None = None, ) -> None: """ @@ -2187,6 +2270,8 @@ def __init__( This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 and nothing for the remaining dimensions. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, take a 3D affine as example:: @@ -2197,12 +2282,17 @@ def __init__( [params[4], params[5], 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] - + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select pixel/voxel to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. This allows 0 to correspond to no change (i.e., a scaling of 1.0). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. spatial_size: output image spatial size. if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1, the transform will use the spatial size of `img`. @@ -2221,6 +2311,15 @@ def __init__( When `mode` is an integer, using numpy/cupy backends, this argument accepts {'reflect', 'grid-mirror', 'constant', 'grid-constant', 'nearest', 'mirror', 'grid-wrap', 'wrap'}. See also: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.map_coordinates.html + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. cache_grid: whether to cache the identity sampling grid. If the spatial size is not dynamically defined by input image, enabling this option could accelerate the transform. @@ -2242,6 +2341,7 @@ def __init__( prob_translate=prob_translate, scale_range=scale_range, prob_scale=prob_scale, + foreground_oversampling_prob=foreground_oversampling_prob, device=device, ) self.resampler = Resample(device=device) @@ -2364,7 +2464,7 @@ def __call__( if grid is None: grid = self.get_identity_grid(sp_size) if self._do_transform: - grid = self.rand_affine_grid(grid=grid, randomize=randomize) + grid = self.rand_affine_grid(grid=grid, image_size=img.shape[1:], randomize=randomize) affine = self.rand_affine_grid.get_transformation_matrix() return affine_func( # type: ignore img, diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index d882432006..64babdcc49 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -834,6 +834,8 @@ def __init__( mode: SequenceStr = GridSampleMode.BILINEAR, padding_mode: SequenceStr = GridSamplePadMode.REFLECTION, cache_grid: bool = False, + foreground_oversampling_prob: float = None, + label_key_for_foreground_oversampling: str = None, device: torch.device | None = None, allow_missing_keys: bool = False, ) -> None: @@ -854,6 +856,8 @@ def __init__( This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` for dim0 and nothing for the remaining dimensions. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, take a 3D affine as example:: @@ -865,11 +869,17 @@ def __init__( [0.0, 0.0, 0.0, 1.0], ] + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. translate_range: translate range with format matching `rotate_range`, it defines the range to randomly select pixel/voxel to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. This allows 0 to correspond to no change (i.e., a scaling of 1.0). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. mode: {``"bilinear"``, ``"nearest"``} or spline interpolation order 0-5 (integers). Interpolation mode to calculate output values. Defaults to ``"bilinear"``. See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.grid_sample.html @@ -887,16 +897,33 @@ def __init__( cache_grid: whether to cache the identity sampling grid. If the spatial size is not dynamically defined by input image, enabling this option could accelerate the transform. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. + label_key_for_foreground_oversampling: key of metatensor whose metadata contains the list of foreground + locations. The list of foreground locations has to be stored under + data[label_key_for_foreground_oversampling].meta['foreground_sample_locations']. + `monai.transforms.SampleForegroundLocations` transform can be used to create and store the list of + foreground locations. device: device on which the tensor will be allocated. allow_missing_keys: don't raise exception if key is missing. See also: - :py:class:`monai.transforms.compose.MapTransform` - :py:class:`RandAffineGrid` for the random affine parameters configurations. + - :py:class:`monai.transforms.SampleForegroundLocations` to sample foreground locations and save in metadata """ MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) + self.label_key_for_foreground_oversampling = label_key_for_foreground_oversampling + self.foreground_oversampling_prob = foreground_oversampling_prob self.rand_affine = RandAffine( prob=1.0, # because probability handled in this class rotate_range=rotate_range, @@ -909,6 +936,7 @@ def __init__( prob_scale=prob_scale, spatial_size=spatial_size, cache_grid=cache_grid, + foreground_oversampling_prob=foreground_oversampling_prob, device=device, ) self.mode = ensure_tuple_rep(mode, len(self.keys)) @@ -936,7 +964,8 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N self.rand_affine.randomize() item = d[first_key] - spatial_size = item.peek_pending_shape() if isinstance(item, MetaTensor) else item.shape[1:] + spatial_size = d[first_key].shape[1:] + # item.peek_pending_shape() if isinstance(item, MetaTensor) else item.shape[1:] sp_size = fall_back_tuple(self.rand_affine.spatial_size, spatial_size) # change image size or do random transform @@ -946,7 +975,15 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N if do_resampling: # need to prepare grid grid = self.rand_affine.get_identity_grid(sp_size) if self._do_transform: # add some random factors - grid = self.rand_affine.rand_affine_grid(sp_size, grid=grid) + if self.foreground_oversampling_prob is not None: + fg_indices = d[self.label_key_for_foreground_oversampling].meta['foreground_sample_locations'] + else: + fg_indices = None + + grid = self.rand_affine.rand_affine_grid(spatial_size=sp_size, + grid=grid, + image_size=spatial_size, + fg_indices=fg_indices) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): # do the transform From 17982ffc28b52c01628540d402a84ee180abbc4f Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Mon, 17 Apr 2023 13:41:30 +0100 Subject: [PATCH 10/24] Adjusted expected values for unit tests --- tests/test_rand_affine.py | 16 +-- tests/test_rand_affine_grid.py | 246 ++++++++++++++------------------- tests/test_rand_affined.py | 51 ++++--- 3 files changed, 137 insertions(+), 176 deletions(-) diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 83aafe9773..95004fdfd6 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -70,7 +70,7 @@ device=device, ), {"img": p(torch.ones((1, 3, 3, 3))), "mode": "bilinear"}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), + p(torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]])), ] ) TESTS.append( @@ -86,7 +86,7 @@ device=device, ), {"img": p(torch.ones((1, 3, 3, 3))), "mode": "bilinear"}, - p(torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]])), + p(torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]])), ] ) TESTS.append( @@ -101,9 +101,9 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8))), "spatial_size": (3, 3)}, p( - torch.tensor( - [[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]] - ) + torch.tensor([[[32.1092, 22.3571, 12.6049], + [38.1935, 28.4413, 18.6892], + [44.2777, 34.5256, 24.7735]]]) ), ] ) @@ -121,9 +121,9 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8)))}, p( - torch.tensor( - [[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]] - ) + torch.tensor([[[32.1092, 22.3571, 12.6049], + [38.1935, 28.4413, 18.6892], + [44.2777, 34.5256, 24.7735]]]) ), ] ) diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index 113987a85c..ad7ac262c2 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -32,19 +32,17 @@ {"grid": p(torch.arange(0, 27).reshape((3, 3, 3)))}, p( np.array( - [ - [ - [-32.81998, -33.910976, -35.001972], - [-36.092968, -37.183964, -38.27496], - [-39.36596, -40.456955, -41.54795], - ], - [ - [2.1380205, 3.1015975, 4.0651755], - [5.028752, 5.9923296, 6.955907], - [7.919484, 8.883063, 9.84664], - ], - [[18.0, 19.0, 20.0], [21.0, 22.0, 23.0], [24.0, 25.0, 26.0]], - ] + [[[17.7142, 19.8156, 21.9171], + [24.0185, 26.1199, 28.2214], + [30.3228, 32.4242, 34.5257]], + + [[58.8789, 62.1901, 65.5013], + [68.8125, 72.1238, 75.4350], + [78.7462, 82.0574, 85.3686]], + + [[18.0000, 19.0000, 20.0000], + [21.0000, 22.0000, 23.0000], + [24.0000, 25.0000, 26.0000]]] ) ), ] @@ -54,64 +52,53 @@ {"translate_range": (3, 3, 3), "device": device}, {"spatial_size": (3, 3, 3)}, np.array( - [ - [ - [ - [0.17881513, 0.17881513, 0.17881513], - [0.17881513, 0.17881513, 0.17881513], - [0.17881513, 0.17881513, 0.17881513], - ], - [ - [1.1788151, 1.1788151, 1.1788151], - [1.1788151, 1.1788151, 1.1788151], - [1.1788151, 1.1788151, 1.1788151], - ], - [ - [2.1788151, 2.1788151, 2.1788151], - [2.1788151, 2.1788151, 2.1788151], - [2.1788151, 2.1788151, 2.1788151], - ], - ], - [ - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], - [ - [-2.283164, -2.283164, -2.283164], - [-1.283164, -1.283164, -1.283164], - [-0.28316402, -0.28316402, -0.28316402], - ], - ], - [ - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], - [ - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - [-2.6388912, -1.6388912, -0.6388912], - ], - ], - [ - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - [[1.0, 1.0, 1.0], [1.0, 1.0, 1.0], [1.0, 1.0, 1.0]], - ], - ] + [[[[-0.6921, -0.6921, -0.6921], + [-0.6921, -0.6921, -0.6921], + [-0.6921, -0.6921, -0.6921]], + + [[0.3079, 0.3079, 0.3079], + [0.3079, 0.3079, 0.3079], + [0.3079, 0.3079, 0.3079]], + + [[1.3079, 1.3079, 1.3079], + [1.3079, 1.3079, 1.3079], + [1.3079, 1.3079, 1.3079]]], + + [[[0.3168, 0.3168, 0.3168], + [1.3168, 1.3168, 1.3168], + [2.3168, 2.3168, 2.3168]], + + [[0.3168, 0.3168, 0.3168], + [1.3168, 1.3168, 1.3168], + [2.3168, 2.3168, 2.3168]], + + [[0.3168, 0.3168, 0.3168], + [1.3168, 1.3168, 1.3168], + [2.3168, 2.3168, 2.3168]]], + + [[[-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386]], + + [[-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386]], + + [[-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386], + [-1.4614, -0.4614, 0.5386]]], + + [[[1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000]], + + [[1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000]], + + [[1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000], + [1.0000, 1.0000, 1.0000]]]] ), ] ) @@ -121,76 +108,53 @@ {"grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3)))}, p( np.array( - [ - [ - [ - [-9.4201e00, -8.1672e00, -6.9143e00], - [-5.6614e00, -4.4085e00, -3.1556e00], - [-1.9027e00, -6.4980e-01, 6.0310e-01], - ], - [ - [1.8560e00, 3.1089e00, 4.3618e00], - [5.6147e00, 6.8676e00, 8.1205e00], - [9.3734e00, 1.0626e01, 1.1879e01], - ], - [ - [1.3132e01, 1.4385e01, 1.5638e01], - [1.6891e01, 1.8144e01, 1.9397e01], - [2.0650e01, 2.1902e01, 2.3155e01], - ], - ], - [ - [ - [9.9383e-02, -4.8845e-01, -1.0763e00], - [-1.6641e00, -2.2519e00, -2.8398e00], - [-3.4276e00, -4.0154e00, -4.6032e00], - ], - [ - [-5.1911e00, -5.7789e00, -6.3667e00], - [-6.9546e00, -7.5424e00, -8.1302e00], - [-8.7180e00, -9.3059e00, -9.8937e00], - ], - [ - [-1.0482e01, -1.1069e01, -1.1657e01], - [-1.2245e01, -1.2833e01, -1.3421e01], - [-1.4009e01, -1.4596e01, -1.5184e01], - ], - ], - [ - [ - [5.9635e01, 6.1199e01, 6.2764e01], - [6.4328e01, 6.5892e01, 6.7456e01], - [6.9021e01, 7.0585e01, 7.2149e01], - ], - [ - [7.3714e01, 7.5278e01, 7.6842e01], - [7.8407e01, 7.9971e01, 8.1535e01], - [8.3099e01, 8.4664e01, 8.6228e01], - ], - [ - [8.7792e01, 8.9357e01, 9.0921e01], - [9.2485e01, 9.4049e01, 9.5614e01], - [9.7178e01, 9.8742e01, 1.0031e02], - ], - ], - [ - [ - [8.1000e01, 8.2000e01, 8.3000e01], - [8.4000e01, 8.5000e01, 8.6000e01], - [8.7000e01, 8.8000e01, 8.9000e01], - ], - [ - [9.0000e01, 9.1000e01, 9.2000e01], - [9.3000e01, 9.4000e01, 9.5000e01], - [9.6000e01, 9.7000e01, 9.8000e01], - ], - [ - [9.9000e01, 1.0000e02, 1.0100e02], - [1.0200e02, 1.0300e02, 1.0400e02], - [1.0500e02, 1.0600e02, 1.0700e02], - ], - ], - ] + [[[[-30.7709, -30.5800, -30.3891], + [-30.1981, -30.0072, -29.8163], + [-29.6254, -29.4344, -29.2435]], + + [[-29.0526, -28.8617, -28.6707], + [-28.4798, -28.2889, -28.0980], + [-27.9070, -27.7161, -27.5252]], + + [[-27.3343, -27.1433, -26.9524], + [-26.7615, -26.5706, -26.3796], + [-26.1887, -25.9978, -25.8069]]], + + [[[42.8536, 44.3798, 45.9061], + [47.4324, 48.9586, 50.4849], + [52.0111, 53.5374, 55.0636]], + + [[56.5899, 58.1161, 59.6424], + [61.1686, 62.6949, 64.2211], + [65.7474, 67.2736, 68.7999]], + + [[70.3261, 71.8524, 73.3786], + [74.9049, 76.4312, 77.9574], + [79.4837, 81.0099, 82.5362]]], + + [[[29.3580, 30.0760, 30.7941], + [31.5121, 32.2301, 32.9481], + [33.6661, 34.3842, 35.1022]], + + [[35.8202, 36.5382, 37.2563], + [37.9743, 38.6923, 39.4103], + [40.1283, 40.8464, 41.5644]], + + [[42.2824, 43.0004, 43.7184], + [44.4365, 45.1545, 45.8725], + [46.5905, 47.3085, 48.0266]]], + + [[[81.0000, 82.0000, 83.0000], + [84.0000, 85.0000, 86.0000], + [87.0000, 88.0000, 89.0000]], + + [[90.0000, 91.0000, 92.0000], + [93.0000, 94.0000, 95.0000], + [96.0000, 97.0000, 98.0000]], + + [[99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000]]]] ) ), ] diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index 5c1e2359e8..da634d82cb 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -74,7 +74,7 @@ mode="bilinear", ), {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, - torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]]), + torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]]), ] ) TESTS.append( @@ -93,7 +93,7 @@ "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), }, - torch.tensor([[[18.7362, 15.5820, 12.4278], [27.3988, 24.2446, 21.0904], [36.0614, 32.9072, 29.7530]]]), + torch.tensor([[[32.10919, 22.357067, 12.604945], [38.193462, 28.44134, 18.689217], [44.277733, 34.52561, 24.773487]]]), ] ) TESTS.append( @@ -116,16 +116,14 @@ { "img": MetaTensor( torch.tensor( - [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] - ] + [[[32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487]]] ) ), - "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(torch.tensor([[[35., 19., 12.], + [36., 28., 20.], + [45., 37., 21.]]])), }, ] ) @@ -143,7 +141,10 @@ mode=GridSampleMode.BILINEAR, ), {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, - torch.tensor([[[[0.3658, 1.0000], [1.0000, 1.0000]], [[1.0000, 1.0000], [1.0000, 0.9333]]]]), + torch.tensor([[[[1.0000, 0.7835], + [1.0000, 1.0000]], + [[0.9465, 1.0000], + [0.4705, 1.0000]]]]), ] ) TESTS.append( @@ -166,16 +167,14 @@ { "img": MetaTensor( np.array( - [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] - ] + [[[32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487]]] ) ), - "seg": MetaTensor(np.array([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(np.array([[[35., 19., 12.], + [36., 28., 20.], + [45., 37., 21.]]])), }, ] ) @@ -200,16 +199,14 @@ { "img": MetaTensor( torch.tensor( - [ - [ - [18.736153, 15.581954, 12.4277525], - [27.398798, 24.244598, 21.090399], - [36.061443, 32.90724, 29.753046], - ] - ] + [[[32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487]]] ) ), - "seg": MetaTensor(torch.tensor([[[19.0, 20.0, 12.0], [27.0, 28.0, 20.0], [35.0, 36.0, 29.0]]])), + "seg": MetaTensor(torch.tensor([[[35., 19., 12.], + [36., 28., 20.], + [45., 37., 21.]]])), }, ] ) From 49b2149bb817e6b4d063f30dc3bce3eb881933f0 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Mon, 17 Apr 2023 18:10:59 +0100 Subject: [PATCH 11/24] Added unit tests for foreground_oversampling_prob argument to RandAffine and RandAffined --- tests/test_rand_affine.py | 68 ++++++++++++++++++++++++++++++++++ tests/test_rand_affine_grid.py | 57 ++++++++++++++++++++++++++++ tests/test_rand_affined.py | 39 +++++++++++++++++++ 3 files changed, 164 insertions(+) diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 95004fdfd6..7516958acb 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -127,6 +127,30 @@ ), ] ) + TESTS.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + prob_rotate=1.0, + shear_range=[1, 2], + prob_shear=1.0, + translate_range=[2, 1], + prob_translate=1.0, + scale_range=[0.1, 0.2], + prob_scale=1.0, + spatial_size=(3, 3), + cache_grid=True, + device=device, + ), + {"img": p(torch.arange(64).reshape((1, 8, 8)))}, + p( + torch.tensor([[[32.1092, 22.3571, 12.6049], + [38.1935, 28.4413, 18.6892], + [44.2777, 34.5256, 24.7735]]]) + ), + ] + ) TEST_CASES_SKIPPED_CONSISTENCY = [] for p in TEST_NDARRAYS_ALL: @@ -138,6 +162,34 @@ for initial_randomize in (False, True): TEST_RANDOMIZE.append((initial_randomize, cache_grid)) +TEST_FOREGROUND_OVERSAMPLING = [] +for p in TEST_NDARRAYS_ALL: + TEST_FOREGROUND_OVERSAMPLING.append( + [ + dict( + prob=0.9, + rotate_range=(np.pi / 2,), + prob_rotate=1.0, + shear_range=[1, 2], + prob_shear=1.0, + translate_range=[2, 1], + prob_translate=1.0, + scale_range=[0.1, 0.2], + prob_scale=1.0, + spatial_size=(3, 3), + foreground_oversampling_prob=0.5, + cache_grid=True, + device=device, + ), + {"img": p(torch.arange(64).reshape((1, 8, 8)))}, + p( + torch.tensor([[[1.100917, 3.219612, 9.162791], + [7.743001, 13.68618, 19.629358], + [18.209568, 24.152748, 30.095926]]]) + ), + ] + ) + class TestRandAffine(unittest.TestCase): @parameterized.expand(TESTS) @@ -196,6 +248,22 @@ def test_no_randomize(self, initial_randomize, cache_grid): assert_allclose(m1, m2) assert_allclose(arr1, arr2) + @parameterized.expand(TEST_FOREGROUND_OVERSAMPLING) + def test_rand_affine(self, input_param, input_data, expected_val): + g = RandAffine(**input_param) + g.set_random_state(123) + + grid = g.rand_affine_grid(spatial_size=input_param['spatial_size'], + grid=None, + image_size=input_data['img'].shape[1:], + fg_indices=[[1, 2], [2, 2]]) + + result = g(**input_data, grid=grid) + test_resampler_lazy(g, result, input_param, input_data, seed=123) + if input_param.get("cache_grid", False): + self.assertTrue(g._cached_grid is not None) + assert_allclose(result, expected_val, rtol=_rtol, atol=1e-4, type_test="tensor") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index ad7ac262c2..ca62e785ec 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -159,6 +159,63 @@ ), ] ) + TESTS.append( + [ + {"device": device, "rotate_range": (1.0, 1.0, 1.0), "shear_range": (0.1,), "scale_range": (1.2,), + "foreground_oversampling_prob": 0.9}, + {"grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3))), "image_size": [5, 5, 5], "fg_indices": + [[1, 1, 1, 1], [1, 2, 2, 2]]}, + p( + np.array([[[[-50.6643, -50.7190, -50.7737], + [-50.8284, -50.8830, -50.9377], + [-50.9924, -51.0471, -51.1017]], + + [[-51.1564, -51.2111, -51.2657], + [-51.3204, -51.3751, -51.4298], + [-51.4844, -51.5391, -51.5938]], + + [[-51.6485, -51.7031, -51.7578], + [-51.8125, -51.8671, -51.9218], + [-51.9765, -52.0312, -52.0858]]], + + [[[-90.4777, -90.5975, -90.7173], + [-90.8371, -90.9569, -91.0767], + [-91.1965, -91.3164, -91.4362]], + + [[-91.5560, -91.6758, -91.7956], + [-91.9154, -92.0352, -92.1550], + [-92.2748, -92.3947, -92.5145]], + + [[-92.6343, -92.7541, -92.8739], + [-92.9937, -93.1135, -93.2334], + [-93.3532, -93.4730, -93.5928]]], + + [[[-34.4885, -34.5587, -34.6289], + [-34.6991, -34.7693, -34.8395], + [-34.9097, -34.9799, -35.0501]], + + [[-35.1203, -35.1906, -35.2608], + [-35.3310, -35.4012, -35.4714], + [-35.5416, -35.6118, -35.6820]], + + [[-35.7522, -35.8224, -35.8926], + [-35.9628, -36.0330, -36.1032], + [-36.1735, -36.2437, -36.3139]]], + + [[[81.0000, 82.0000, 83.0000], + [84.0000, 85.0000, 86.0000], + [87.0000, 88.0000, 89.0000]], + + [[90.0000, 91.0000, 92.0000], + [93.0000, 94.0000, 95.0000], + [96.0000, 97.0000, 98.0000]], + + [[99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000]]]]) + ), + ] + ) class TestRandAffineGrid(unittest.TestCase): diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index da634d82cb..fe8b610c0b 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -211,6 +211,43 @@ ] ) + seg = MetaTensor(torch.arange(64).reshape((1, 8, 8))) + seg.meta['foreground_sample_locations'] = [[1, 2, 3], [2, 3, 4]] + TESTS.append( + [ + dict( + prob=0.9, + mode=(GridSampleMode.BILINEAR, GridSampleMode.NEAREST), + rotate_range=(np.pi / 2,), + shear_range=[1, 2], + translate_range=[2, 1], + scale_range=[0.1, 0.2], + spatial_size=(3, 3), + foreground_oversampling_prob=0.9, + label_key_for_foreground_oversampling="seg", + cache_grid=True, + keys=("img", "seg"), + device=device, + ), + { + "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), + "seg": seg, + }, + { + "img": MetaTensor( + torch.tensor( + [[[25.449093, 24.070652, 21.291996], + [28.34988, 27.144796, 25.939713], + [31.250668, 30.045584, 28.8405]]] + ) + ), + "seg": MetaTensor(torch.tensor([[[22., 23., 23.], + [28., 30., 23.], + [35., 28., 29.]]])), + }, + ] + ) + class TestRandAffined(unittest.TestCase): @parameterized.expand(x + [y] for x, y in itertools.product(TESTS, (False, True))) @@ -223,6 +260,8 @@ def test_rand_affined(self, input_param, input_data, expected_val, track_meta): if track_meta and input_data["img"].ndim in (3, 4): if "mode" not in input_param.keys(): input_param["mode"] = "bilinear" + if "padding_mode" not in input_param.keys(): + input_param["padding_mode"] = "reflection" if not isinstance(input_param["keys"], str): input_param["mode"] = ensure_tuple_rep(input_param["mode"], len(input_param["keys"])) lazy_init_param = input_param.copy() From b224971d22acae4de77ce1e12c649bbe611aed7e Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Tue, 18 Apr 2023 14:52:14 +0100 Subject: [PATCH 12/24] Added RandSimulateLowResolution and RandSimulateLowResolutiond with unit tests. --- monai/transforms/__init__.py | 4 + monai/transforms/spatial/array.py | 95 ++++++++++++++++++++- monai/transforms/spatial/dictionary.py | 84 ++++++++++++++++++ tests/test_rand_simulate_low_resolution.py | 85 ++++++++++++++++++ tests/test_rand_simulate_low_resolutiond.py | 65 ++++++++++++++ 5 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 tests/test_rand_simulate_low_resolution.py create mode 100644 tests/test_rand_simulate_low_resolutiond.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 385c08565d..59114f9929 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -384,6 +384,7 @@ Rotate, Rotate90, Spacing, + RandSimulateLowResolution, SpatialResample, Zoom, ) @@ -451,6 +452,9 @@ Spacingd, SpacingD, SpacingDict, + RandSimulateLowResolutiond, + RandSimulateLowResolutionD, + RandSimulateLowResolutionDict, SpatialResampled, SpatialResampleD, SpatialResampleDict, diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index cfd6e04b53..2796e6c4be 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -27,7 +27,7 @@ from monai.config import USE_COMPILED, DtypeLike from monai.config.type_definitions import NdarrayOrTensor -from monai.data.meta_obj import get_track_meta +from monai.data.meta_obj import get_track_meta, set_track_meta from monai.data.meta_tensor import MetaTensor 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 @@ -114,6 +114,7 @@ "RandAffine", "Rand2DElastic", "Rand3DElastic", + "RandSimulateLowResolution", ] RandRange = Optional[Union[Sequence[Union[Tuple[float, float], float]], float]] @@ -3371,3 +3372,95 @@ def __call__(self, array: NdarrayOrTensor, randomize: bool = True): if randomize: self.randomize(array) return super().__call__(array) + + +class RandSimulateLowResolution(RandomizableTransform): + """ + Random simulation of low resolution corresponding to nnU-Net's SimulateLowResolutionTransform + (https://github.com/MIC-DKFZ/batchgenerators/blob/7651ece69faf55263dd582a9f5cbd149ed9c3ad0/batchgenerators/transforms/resample_transforms.py#L23) + First, the array/tensor is resampled at lower resolution as determined by the zoom_factor which is uniformly sampled + from the `zoom_range`. Then, the array/tensor is resampled at the original resolution. + """ + + backend = Affine.backend + + def __init__( + self, + prob: float = 0.1, + downsample_mode: InterpolateMode | str = InterpolateMode.NEAREST, + upsample_mode: InterpolateMode | str = InterpolateMode.TRILINEAR, + zoom_range: Sequence = (0.5, 1.0), + align_corners=False, + device: Optional[torch.device] = None, + ) -> None: + """ + Args: + prob: probability of performing this augmentation + downsample_mode: how to downsample + upsample_mode: how to upsample + zoom_range: range from which the random zoom factor for the downsampling operation is sampled. It determines + the shape of the downsampled tensor. + align_corners: his only has an effect when downsample_mode or upsample_mode is 'linear', 'bilinear', + 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + device: device on which the tensor will be allocated. + + """ + RandomizableTransform.__init__(self, prob) + + self.downsample_mode = downsample_mode + self.upsample_mode = upsample_mode + self.zoom_range = zoom_range + self.align_corners = align_corners + self.device = device + self.zoom_factor = 1 + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + self.zoom_factor = self.R.uniform(self.zoom_range[0], self.zoom_range[1]) + if not self._do_transform: + return None + + def __call__( + self, + img: torch.Tensor, + randomize: bool = True, + ) -> torch.Tensor: + """ + Args: + img: shape must be (num_channels, H, W[, D]), + """ + if randomize: + self.randomize() + + if self._do_transform: + + input_shape = np.array(img.shape[1:]) + target_shape = np.round(input_shape * self.zoom_factor).astype(np.int_) + + resize_tfm_downsample = Resize(spatial_size=target_shape, + size_mode='all', + mode=self.downsample_mode, + anti_aliasing=False, + ) + + resize_tfm_upsample = Resize(spatial_size=input_shape, + size_mode='all', + mode=self.upsample_mode, + anti_aliasing=False, + align_corners=self.align_corners + ) + # temporarily disable metadata tracking, since we do not want to invert the two Resize functions in post-processing + original_tack_meta_value = get_track_meta() + set_track_meta(False) + + img_downsampled = resize_tfm_downsample(img) + img_upsampled = resize_tfm_upsample(img_downsampled) + set_track_meta(original_tack_meta_value) + + img_upsampled = MetaTensor(img_upsampled) + img_upsampled.copy_meta_from(img) + return img_upsampled + + else: + return img diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 64babdcc49..304acb10de 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -45,6 +45,7 @@ RandGridDistortion, RandGridPatch, RandRotate, + RandSimulateLowResolution, RandZoom, ResampleToMatch, Resize, @@ -140,6 +141,9 @@ "RandGridPatchd", "RandGridPatchD", "RandGridPatchDict", + "RandSimulateLowResolutiond", + "RandSimulateLowResolutionD", + "RandSimulateLowResolutionDict", ] @@ -2146,6 +2150,85 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class RandSimulateLowResolutiond(RandomizableTransform, MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.RandSimulateLowResolution`. + Random simulation of low resolution corresponding to nnU-Net's SimulateLowResolutionTransform + (https://github.com/MIC-DKFZ/batchgenerators/blob/7651ece69faf55263dd582a9f5cbd149ed9c3ad0/batchgenerators/transforms/resample_transforms.py#L23) + First, the array/tensor is resampled at lower resolution as determined by the zoom_factor which is uniformly sampled + from the `zoom_range`. Then, the array/tensor is resampled at the original resolution. + """ + + backend = RandAffine.backend + + def __init__( + self, + keys: KeysCollection, + prob: float = 0.1, + downsample_mode: InterpolateMode | str = InterpolateMode.NEAREST, + upsample_mode: InterpolateMode | str = InterpolateMode.TRILINEAR, + zoom_range=(0.5, 1.0), + align_corners=False, + allow_missing_keys: bool = False, + device: torch.device | None = None, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + prob: probability of performing this augmentation + downsample_mode: how to downsample + upsample_mode: how to upsample + zoom_range: range from which the random zoom factor for the downsampling operation is sampled. It determines + the shape of the downsampled tensor. + align_corners: his only has an effect when downsample_mode or upsample_mode is 'linear', 'bilinear', + 'bicubic' or 'trilinear'. Default: None. + See also: https://pytorch.org/docs/stable/generated/torch.nn.functional.interpolate.html + device: device on which the tensor will be allocated. + allow_missing_keys: don't raise exception if key is missing. + + See also: + - :py:class:`monai.transforms.compose.MapTransform` + + """ + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + + self.downsample_mode = downsample_mode + self.upsample_mode = upsample_mode + self.zoom_range = zoom_range + self.align_corners = align_corners + self.device = device + + self.sim_lowres_tfm = RandSimulateLowResolution(prob=1.0, # probability is handled by dictionary class + downsample_mode=self.downsample_mode, + upsample_mode=self.upsample_mode, + zoom_range=self.zoom_range, + align_corners=self.align_corners, + device=self.device) + def set_random_state( + self, seed: int | None = None, state: np.random.RandomState | None = None + ) -> "RandSimulateLowResolutiond": + super().set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + first_key: Hashable = self.first_key(d) + if first_key == (): + out: dict[Hashable, NdarrayOrTensor] = convert_to_tensor(d, track_meta=get_track_meta()) + return out + + self.randomize(None) + + for key, downsample_mode, upsample_mode in self.key_iterator(d, self.downsample_mode, self.upsample_mode): + # do the transform + if self._do_transform: + d[key] = self.sim_lowres_tfm(d[key]) # type: ignore + else: + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta(), dtype=torch.float32) + return d + + SpatialResampleD = SpatialResampleDict = SpatialResampled ResampleToMatchD = ResampleToMatchDict = ResampleToMatchd SpacingD = SpacingDict = Spacingd @@ -2169,3 +2252,4 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N GridSplitD = GridSplitDict = GridSplitd GridPatchD = GridPatchDict = GridPatchd RandGridPatchD = RandGridPatchDict = RandGridPatchd +RandSimulateLowResolutionD = RandSimulateLowResolutionDict = RandSimulateLowResolutiond diff --git a/tests/test_rand_simulate_low_resolution.py b/tests/test_rand_simulate_low_resolution.py new file mode 100644 index 0000000000..1161ed3d70 --- /dev/null +++ b/tests/test_rand_simulate_low_resolution.py @@ -0,0 +1,85 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandSimulateLowResolution +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + dict( + prob=1.0, + zoom_range=(0.8, 0.81), + ), + p(np.array( + [[[[0, 1, 2, 3], + [4, 5, 6, 7], + [8, 9, 10, 11], + [12, 13, 14, 15]], + + [[16, 17, 18, 19], + [20, 21, 22, 23], + [24, 25, 26, 27], + [28, 29, 30, 31]], + + [[32, 33, 34, 35], + [36, 37, 38, 39], + [40, 41, 42, 43], + [44, 45, 46, 47]], + + [[48, 49, 50, 51], + [52, 53, 54, 55], + [56, 57, 58, 59], + [60, 61, 62, 63]]]] + )), + + np.array([[[[0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000]], + + [[10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000]], + + [[22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000]], + + [[32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000]]]]) + ] + ) + + +class TestRandGaussianSmooth(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, arguments, image, expected_data): + randsimlowres = RandSimulateLowResolution(**arguments) + randsimlowres.set_random_state(seed=0) + result = randsimlowres(image) + assert_allclose(result, expected_data, rtol=1e-4, type_test="tensor") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_simulate_low_resolutiond.py b/tests/test_rand_simulate_low_resolutiond.py new file mode 100644 index 0000000000..0925922da6 --- /dev/null +++ b/tests/test_rand_simulate_low_resolutiond.py @@ -0,0 +1,65 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandSimulateLowResolutiond +from tests.utils import TEST_NDARRAYS, assert_allclose + +TESTS = [] +for p in TEST_NDARRAYS: + TESTS.append( + [ + dict(keys=["img", "seg"], + prob=1.0, + zoom_range=(0.8, 0.81)), + {"img": p(np.arange(64).reshape(1, 4, 4, 4)), + "seg": p(np.arange(64).reshape(1, 4, 4, 4))}, + np.array([[[[0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000]], + + [[10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000]], + + [[22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000]], + + [[32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000]]]]), + ] + ) + + +class TestRandGaussianSmoothd(unittest.TestCase): + @parameterized.expand(TESTS) + def test_value(self, arguments, image, expected_data): + converter = RandSimulateLowResolutiond(**arguments) + converter.set_random_state(seed=0) + result = converter(image) + assert_allclose(result["img"], expected_data, rtol=1e-4, type_test=False) + assert_allclose(result["seg"], expected_data, rtol=1e-4, type_test=False) + + +if __name__ == "__main__": + unittest.main() From feb11c39317a7b0c20c70688749d59a3f047431e Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Wed, 19 Apr 2023 09:59:43 +0100 Subject: [PATCH 13/24] Added arguments 'invert_image' and 'retain_stats' to AdjustContrast and AdjustContrastd and extended unit tests. --- monai/transforms/intensity/array.py | 51 +++++++++++++++++++++--- monai/transforms/intensity/dictionary.py | 26 +++++++++--- tests/test_adjust_contrast.py | 44 +++++++++++++++----- tests/test_adjust_contrastd.py | 51 +++++++++++++++++------- 4 files changed, 138 insertions(+), 34 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 588913f579..c8e657ad5e 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -800,36 +800,65 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: class AdjustContrast(Transform): """ - Changes image intensity by gamma. Each pixel/voxel intensity is updated as:: + Changes image intensity with gamma transform. Each pixel/voxel intensity is updated as:: x = ((x - min) / intensity_range) ^ gamma * intensity_range + min Args: gamma: gamma value to adjust the contrast as function. + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, gamma: float) -> None: + def __init__(self, + gamma: float, + invert_image: bool = False, + retain_stats: bool = False) -> None: + if not isinstance(gamma, (int, float)): raise ValueError(f"gamma must be a float or int number, got {type(gamma)} {gamma}.") self.gamma = gamma + self.invert_image = invert_image + self.retain_stats = retain_stats def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ img = convert_to_tensor(img, track_meta=get_track_meta()) + + if self.invert_image: + img = -img + + if self.retain_stats: + mn = img.mean() + sd = img.std() + epsilon = 1e-7 img_min = img.min() img_range = img.max() - img_min ret: NdarrayOrTensor = ((img - img_min) / float(img_range + epsilon)) ** self.gamma * img_range + img_min + + if self.retain_stats: + # zero mean and normalize + ret = (ret - ret.mean()) + ret = ret / (ret.std() + 1e-8) + # restore old mean and standard deviation + ret = sd*ret + mn + + if self.invert_image: + ret = -ret + return ret class RandAdjustContrast(RandomizableTransform): """ - Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as:: + Randomly changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: x = ((x - min) / intensity_range) ^ gamma * intensity_range + min @@ -837,11 +866,20 @@ class RandAdjustContrast(RandomizableTransform): prob: Probability of adjustment. gamma: Range of gamma values. If single number, value is picked from (0.5, gamma), default is (0.5, 4.5). + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input """ backend = AdjustContrast.backend - def __init__(self, prob: float = 0.1, gamma: Sequence[float] | float = (0.5, 4.5)) -> None: + def __init__(self, + prob: float = 0.1, + gamma: Sequence[float] | float = (0.5, 4.5), + invert_image: bool = False, + retain_stats: bool = False,) \ + -> None: RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): @@ -856,6 +894,8 @@ def __init__(self, prob: float = 0.1, gamma: Sequence[float] | float = (0.5, 4.5 self.gamma = (min(gamma), max(gamma)) self.gamma_value: float | None = None + self.invert_image: bool = invert_image + self.retain_stats: bool = retain_stats def randomize(self, data: Any | None = None) -> None: super().randomize(None) @@ -876,7 +916,8 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if self.gamma_value is None: raise RuntimeError("gamma_value is not set, please call `randomize` function first.") - return AdjustContrast(self.gamma_value)(img) + + return AdjustContrast(self.gamma_value, invert_image=self.invert_image, retain_stats=self.retain_stats)(img) class ScaleIntensityRangePercentiles(Transform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 790cb38671..3b6f6d45f6 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -798,7 +798,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N class AdjustContrastd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AdjustContrast`. - Changes image intensity by gamma. Each pixel/voxel intensity is updated as: + Changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: `x = ((x - min) / intensity_range) ^ gamma * intensity_range + min` @@ -806,14 +806,23 @@ class AdjustContrastd(MapTransform): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform gamma: gamma value to adjust the contrast as function. + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input allow_missing_keys: don't raise exception if key is missing. """ backend = AdjustContrast.backend - def __init__(self, keys: KeysCollection, gamma: float, allow_missing_keys: bool = False) -> None: + def __init__(self, + keys: KeysCollection, + gamma: float, + invert_image: bool = False, + retain_stats: bool = False, + allow_missing_keys: bool = False) -> None: super().__init__(keys, allow_missing_keys) - self.adjuster = AdjustContrast(gamma) + self.adjuster = AdjustContrast(gamma, invert_image, retain_stats) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: d = dict(data) @@ -825,7 +834,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N class RandAdjustContrastd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandAdjustContrast`. - Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as: + Randomly changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: `x = ((x - min) / intensity_range) ^ gamma * intensity_range + min` @@ -835,6 +844,10 @@ class RandAdjustContrastd(RandomizableTransform, MapTransform): prob: Probability of adjustment. gamma: Range of gamma values. If single number, value is picked from (0.5, gamma), default is (0.5, 4.5). + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input allow_missing_keys: don't raise exception if key is missing. """ @@ -845,11 +858,14 @@ def __init__( keys: KeysCollection, prob: float = 0.1, gamma: tuple[float, float] | float = (0.5, 4.5), + invert_image: bool = False, + retain_stats: bool = False, allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) - self.adjuster = RandAdjustContrast(gamma=gamma, prob=1.0) + self.adjuster = RandAdjustContrast(gamma=gamma, prob=1.0, invert_image=invert_image, retain_stats=retain_stats) + self.invert_image = invert_image def set_random_state( self, seed: int | None = None, state: np.random.RandomState | None = None diff --git a/tests/test_adjust_contrast.py b/tests/test_adjust_contrast.py index c239f43346..4b2c718e49 100644 --- a/tests/test_adjust_contrast.py +++ b/tests/test_adjust_contrast.py @@ -19,29 +19,55 @@ from monai.transforms import AdjustContrast from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASE_1 = [1.0] +TESTS = [] +for invert_image in (True, False): + for retain_stats in (True, False): + TEST_CASE_1 = [1.0, invert_image, retain_stats] + TEST_CASE_2 = [0.5, invert_image, retain_stats] + TEST_CASE_3 = [4.5, invert_image, retain_stats] -TEST_CASE_2 = [0.5] - -TEST_CASE_3 = [4.5] + TESTS.extend([TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + ]) class TestAdjustContrast(NumpyImageTestCase2D): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_correct_results(self, gamma): - adjuster = AdjustContrast(gamma=gamma) + @parameterized.expand(TESTS) + def test_correct_results(self, gamma, invert_image, retain_stats): + adjuster = AdjustContrast(gamma=gamma, invert_image=invert_image, retain_stats=retain_stats) for p in TEST_NDARRAYS: im = p(self.imt) result = adjuster(im) self.assertTrue(type(im), type(result)) - if gamma == 1.0: + if False: # gamma == 1.0: expected = self.imt else: + + if invert_image: + self.imt = -self.imt + + if retain_stats: + mn = self.imt.mean() + sd = self.imt.std() + epsilon = 1e-7 img_min = self.imt.min() img_range = self.imt.max() - img_min + expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(result, expected, rtol=1e-05, type_test="tensor") + + if retain_stats: + # zero mean and normalize + expected = (expected - expected.mean()) + expected = expected / (expected.std() + 1e-8) + # restore old mean and standard deviation + expected = sd * expected + mn + + if invert_image: + expected = -expected + + assert_allclose(result, expected, atol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_adjust_contrastd.py b/tests/test_adjust_contrastd.py index 6de2658a5b..9d3435394d 100644 --- a/tests/test_adjust_contrastd.py +++ b/tests/test_adjust_contrastd.py @@ -19,27 +19,48 @@ from monai.transforms import AdjustContrastd from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASE_1 = [1.0] +TESTS = [] +for invert_image in (True, False): + for retain_stats in (True, False): + TEST_CASE_1 = [1.0, invert_image, retain_stats] + TEST_CASE_2 = [0.5, invert_image, retain_stats] + TEST_CASE_3 = [4.5, invert_image, retain_stats] -TEST_CASE_2 = [0.5] - -TEST_CASE_3 = [4.5] + TESTS.extend([TEST_CASE_1, + TEST_CASE_2, + TEST_CASE_3, + ]) class TestAdjustContrastd(NumpyImageTestCase2D): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_correct_results(self, gamma): - adjuster = AdjustContrastd("img", gamma=gamma) + @parameterized.expand(TESTS) + def test_correct_results(self, gamma, invert_image, retain_stats): + adjuster = AdjustContrastd("img", gamma=gamma, invert_image=invert_image, retain_stats=retain_stats) for p in TEST_NDARRAYS: result = adjuster({"img": p(self.imt)}) - if gamma == 1.0: - expected = self.imt - else: - epsilon = 1e-7 - img_min = self.imt.min() - img_range = self.imt.max() - img_min - expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(result["img"], expected, rtol=1e-05, type_test="tensor") + if invert_image: + self.imt = -self.imt + + if retain_stats: + mn = self.imt.mean() + sd = self.imt.std() + + epsilon = 1e-7 + img_min = self.imt.min() + img_range = self.imt.max() - img_min + + expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min + + if retain_stats: + # zero mean and normalize + expected = (expected - expected.mean()) + expected = expected / (expected.std() + 1e-8) + # restore old mean and standard deviation + expected = sd * expected + mn + + if invert_image: + expected = -expected + assert_allclose(result["img"], expected, atol=1e-05, type_test="tensor") if __name__ == "__main__": From 7bd30f335e43d2a6e8305c8cb7c2a6c6fa82db1d Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Wed, 19 Apr 2023 15:59:23 +0100 Subject: [PATCH 14/24] Added ScaleIntensityFixedMean, RandScaleIntensityFixedMean and RandScaleIntensityFixedMeand with unit tests. --- monai/transforms/__init__.py | 5 + monai/transforms/intensity/array.py | 157 ++++++++++++++++++ monai/transforms/intensity/dictionary.py | 70 +++++++- tests/test_rand_scale_intensity_fixed_mean.py | 41 +++++ .../test_rand_scale_intensity_fixed_meand.py | 41 +++++ tests/test_scale_intensity_fixed_mean.py | 91 ++++++++++ 6 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 tests/test_rand_scale_intensity_fixed_mean.py create mode 100644 tests/test_rand_scale_intensity_fixed_meand.py create mode 100644 tests/test_scale_intensity_fixed_mean.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 59114f9929..2d79144471 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -118,10 +118,12 @@ RandKSpaceSpikeNoise, RandRicianNoise, RandScaleIntensity, + RandScaleIntensityFixedMean, RandShiftIntensity, RandStdShiftIntensity, SavitzkyGolaySmooth, ScaleIntensity, + ScaleIntensityFixedMean, ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, @@ -198,6 +200,9 @@ RandScaleIntensityd, RandScaleIntensityD, RandScaleIntensityDict, + RandScaleIntensityFixedMeand, + RandScaleIntensityFixedMeanD, + RandScaleIntensityFixedMeanDict, RandShiftIntensityd, RandShiftIntensityD, RandShiftIntensityDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index c8e657ad5e..3055910872 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -49,6 +49,8 @@ "RandBiasField", "ScaleIntensity", "RandScaleIntensity", + "ScaleIntensityFixedMean", + "RandScaleIntensityFixedMean", "NormalizeIntensity", "ThresholdIntensity", "ScaleIntensityRange", @@ -467,6 +469,161 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: return ret +class ScaleIntensityFixedMean(Transform): + """ + Scale the intensity of input image to the given value range (minv, maxv). + If `minv` and `maxv` not provided, use `factor` to scale image by ``v = v * (1 + factor)``. + Subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + factor: float | None = 0, + preserve_range: bool = False, + fixed_mean: bool = True, + channel_wise: bool = False, + dtype: DtypeLike = np.float32, + ) -> None: + """ + Args: + factor: factor scale by ``v = v * (1 + factor)``. + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + """ + self.factor = factor + self.preserve_range = preserve_range + self.fixed_mean = fixed_mean + self.channel_wise = channel_wise + self.dtype = dtype + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Apply the transform to `img`. + + Raises: + ValueError: When ``self.fixed_mean=True`` and ``self.factor=None``. Incompatible values. + + """ + + if self.fixed_mean and not self.factor: + raise ValueError(f"{self.fixed_mean=} and {self.factor=} is incompatible.") + + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) + ret: NdarrayOrTensor + if self.channel_wise: + out = [] + for d in img_t: + if self.preserve_range: + clip_min = d.min() + clip_max = d.max() + + if self.fixed_mean: + mn = d.mean() + d = d - mn + + out_channel = (d * (1 + self.factor)) if self.factor is not None else img_t + + if self.fixed_mean: + out_channel = out_channel + mn + + if self.preserve_range: + out_channel = clip(out_channel, clip_min, clip_max) + + out.append(out_channel) + ret = torch.stack(out) # type: ignore + else: + if self.preserve_range: + clip_min = img_t.min() + clip_max = img_t.max() + + if self.fixed_mean: + mn = img_t.mean() + img_t = img_t - mn + + ret = (img_t * (1 + self.factor)) if self.factor is not None else img_t + + if self.fixed_mean: + ret = ret + mn + + if self.preserve_range: + ret = clip(ret, clip_min, clip_max) + + ret = convert_to_dst_type(ret, dst=img, dtype=self.dtype or img_t.dtype)[0] + return ret + + +class RandScaleIntensityFixedMean(RandomizableTransform): + """ + Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor` + is randomly picked. Subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + """ + + backend = ScaleIntensityFixedMean.backend + + def __init__( + self, + prob: float = 0.1, + factors: Sequence[float] | float = 0, + fixed_mean: bool = True, + preserve_range: bool = False, + dtype: DtypeLike = np.float32 + ) -> None: + """ + Args: + factors: factor range to randomly scale by ``v = v * (1 + factor)``. + if single number, factor value is picked from (-factors, factors). + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + + """ + RandomizableTransform.__init__(self, prob) + if isinstance(factors, (int, float)): + self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") + else: + self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] + self.fixed_mean = fixed_mean + self.preserve_range = preserve_range + self.dtype = dtype + + def randomize(self, data: Any | None = None) -> None: + super().randomize(None) + if not self._do_transform: + return None + self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) + + def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + """ + Apply the transform to `img`. + """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + if randomize: + self.randomize() + + if not self._do_transform: + return convert_data_type(img, dtype=self.dtype)[0] + + return ScaleIntensityFixedMean(factor=self.factor, fixed_mean=self.fixed_mean, + preserve_range=self.preserve_range, dtype=self.dtype)(img) + + class RandScaleIntensity(RandomizableTransform): """ Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor` diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 3b6f6d45f6..c2ebe19c8c 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -56,7 +56,7 @@ ScaleIntensityRangePercentiles, ShiftIntensity, StdShiftIntensity, - ThresholdIntensity, + ThresholdIntensity, RandScaleIntensityFixedMean, ) from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive @@ -108,6 +108,9 @@ "StdShiftIntensityDict", "RandScaleIntensityD", "RandScaleIntensityDict", + "RandScaleIntensityFixedMeand", + "RandScaleIntensityFixedMeanDict", + "RandScaleIntensityFixedMeanD", "RandStdShiftIntensityD", "RandStdShiftIntensityDict", "RandBiasFieldD", @@ -623,6 +626,70 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class RandScaleIntensityFixedMeand(RandomizableTransform, MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.RandScaleIntensity`. + Subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + """ + + backend = RandScaleIntensityFixedMean.backend + + def __init__( + self, + keys: KeysCollection, + factors: Sequence[float, float] | float, + fixed_mean: bool = True, + preserve_range: bool = False, + prob: float = 0.1, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + factors: factor range to randomly scale by ``v = v * (1 + factor)``. + if single number, factor value is picked from (-factors, factors). + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + allow_missing_keys: don't raise exception if key is missing. + + """ + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + self.fixed_mean = fixed_mean + self.preserve_range = preserve_range + self.scaler = RandScaleIntensityFixedMean(factors=factors, fixed_mean=self.fixed_mean, + preserve_range=preserve_range, dtype=dtype, prob=1.0) + + def set_random_state( + self, seed: int | None = None, state: np.random.RandomState | None = None + ) -> "RandScaleIntensityFixedMean": + super().set_random_state(seed, state) + self.scaler.set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + self.randomize(None) + if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + return d + + # all the keys share the same random scale factor + self.scaler.randomize(None) + for key in self.key_iterator(d): + d[key] = self.scaler(d[key], randomize=False) + return d + + class RandBiasFieldd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandBiasField`. @@ -1817,6 +1884,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N RandBiasFieldD = RandBiasFieldDict = RandBiasFieldd ScaleIntensityD = ScaleIntensityDict = ScaleIntensityd RandScaleIntensityD = RandScaleIntensityDict = RandScaleIntensityd +RandScaleIntensityFixedMeanD = RandScaleIntensityFixedMeanDict = RandScaleIntensityFixedMeand NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd ThresholdIntensityD = ThresholdIntensityDict = ThresholdIntensityd ScaleIntensityRangeD = ScaleIntensityRangeDict = ScaleIntensityRanged diff --git a/tests/test_rand_scale_intensity_fixed_mean.py b/tests/test_rand_scale_intensity_fixed_mean.py new file mode 100644 index 0000000000..f43adab32f --- /dev/null +++ b/tests/test_rand_scale_intensity_fixed_mean.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandScaleIntensityFixedMean +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestRandScaleIntensity(NumpyImageTestCase2D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value(self, p): + scaler = RandScaleIntensityFixedMean(prob=1.0, factors=0.5) + scaler.set_random_state(seed=0) + im = p(self.imt) + result = scaler(im) + np.random.seed(0) + # simulate the randomize() of transform + np.random.random() + mn = im.mean() + im = im - mn + expected = (1 + np.random.uniform(low=-0.5, high=0.5)) * im + expected = expected + mn + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_scale_intensity_fixed_meand.py b/tests/test_rand_scale_intensity_fixed_meand.py new file mode 100644 index 0000000000..c85c764a55 --- /dev/null +++ b/tests/test_rand_scale_intensity_fixed_meand.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np + +from monai.transforms import RandScaleIntensityFixedMeand +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestRandScaleIntensityFixedMeand(NumpyImageTestCase2D): + def test_value(self): + key = "img" + for p in TEST_NDARRAYS: + scaler = RandScaleIntensityFixedMeand(keys=[key], factors=0.5, prob=1.0) + scaler.set_random_state(seed=0) + result = scaler({key: p(self.imt)}) + np.random.seed(0) + # simulate the randomize function of transform + np.random.random() + im = self.imt + mn = im.mean() + im = im - mn + expected = (1 + np.random.uniform(low=-0.5, high=0.5)) * im + expected = expected + mn + assert_allclose(result[key], p(expected), type_test="tensor", atol=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scale_intensity_fixed_mean.py b/tests/test_scale_intensity_fixed_mean.py new file mode 100644 index 0000000000..54e0adba62 --- /dev/null +++ b/tests/test_scale_intensity_fixed_mean.py @@ -0,0 +1,91 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import ScaleIntensityFixedMean +from monai.transforms.utils import rescale_array +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestScaleIntensityFixedMean(NumpyImageTestCase2D): + def test_factor_scale(self): + for p in TEST_NDARRAYS: + scaler = ScaleIntensityFixedMean(factor=0.1, fixed_mean=False) + result = scaler(p(self.imt)) + expected = p((self.imt * (1 + 0.1)).astype(np.float32)) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_preserve_range(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean(factor=factor, preserve_range=True, channel_wise=channel_wise, fixed_mean=False) + im = p(self.imt) + result = scaler(im) + + if False: # channel_wise: + out = [] + for d in im: + clip_min = d.min() + clip_max = d.max() + d = (1 + factor) * d + d[d < clip_min] = clip_min + d[d > clip_max] = clip_max + out.append(d) + expected = p(out) + else: + clip_min = im.min() + clip_max = im.max() + im = (1 + factor) * im + im[im < clip_min] = clip_min + im[im > clip_max] = clip_max + expected = im + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_fixed_mean(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean(factor=factor, fixed_mean=True, channel_wise=channel_wise) + im = p(self.imt) + result = scaler(im) + mn = im.mean() + im = im - mn + expected = (1+factor)*im + expected = expected + mn + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_fixed_mean_preserve_range(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean(factor=factor, preserve_range=True, fixed_mean=True, channel_wise=channel_wise) + im = p(self.imt) + clip_min = im.min() + clip_max = im.max() + result = scaler(im) + mn = im.mean() + im = im - mn + expected = (1+factor)*im + expected = expected + mn + expected[expected < clip_min] = clip_min + expected[expected > clip_max] = clip_max + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + +if __name__ == "__main__": + unittest.main() From 140f4cda92ff9817da2123b648ffec885ee5541e Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Thu, 20 Apr 2023 15:35:28 +0100 Subject: [PATCH 15/24] Added PersistentStagedDataset with unit test, and pickle_hashing_transform_names --- monai/data/__init__.py | 2 + monai/data/dataset.py | 139 +++++++++++++++++- monai/data/utils.py | 22 +++ tests/test_persistentstageddataset.py | 203 ++++++++++++++++++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 tests/test_persistentstageddataset.py diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 0e9759aaf1..06255cbe52 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -36,6 +36,7 @@ LMDBDataset, NPZDictItemDataset, PersistentDataset, + PersistentStagedDataset, SmartCacheDataset, ZipDataset, ) @@ -100,6 +101,7 @@ partition_dataset, partition_dataset_classes, pickle_hashing, + pickle_hash_transform_names, rectify_header_sform_qform, remove_extra_metadata, remove_keys, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index 7df19d88d3..c4ac793a8e 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -34,7 +34,7 @@ from torch.utils.data import Dataset as _TorchDataset from torch.utils.data import Subset -from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hashing +from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hashing, pickle_hash_transform_names from monai.transforms import ( Compose, Randomizable, @@ -417,6 +417,143 @@ def _transform(self, index: int): return self._post_transform(pre_random_item) +class PersistentStagedDataset(PersistentDataset): + def __init__( + self, + new_transform: Sequence[Callable] | Callable, + old_transform: Sequence[Callable] | Callable, + data: Sequence, + cache_dir: Path | str | None, + hash_func: Callable[..., bytes] = pickle_hashing, + pickle_module: str = "pickle", + pickle_protocol: int = DEFAULT_PROTOCOL, + hash_transform: Callable[..., bytes] | None = pickle_hash_transform_names, + reset_ops_id: bool = True, + ) -> None: + + self.old_transform = old_transform + if not self.old_transform: + # if no old_transform is passed, create a normal PersistentDataset based on the new_transform + super().__init__(data, new_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, + reset_ops_id) + + else: + # if an old_transform is passed, create a normal PersistentDataset based on the old_transform + super().__init__(data, old_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, + reset_ops_id) + self.new_transform = new_transform + if not isinstance(self.new_transform, Compose): + self.new_transform = Compose(new_transform) + self.new_transform_hash = "" + + if hash_transform: + self.set_combined_transform_hash(hash_transform) + + def set_combined_transform_hash(self, hash_xform_func: Callable[..., bytes]): + """Create a hash from the composition of old and new transforms. Hashable transforms + are deterministic transforms that inherit from `Transform`. We stop + at the first non-deterministic transform, or first that does not + inherit from MONAI's `Transform` class.""" + hashable_transforms = [] + for _tr in self.transform.flatten().transforms+self.new_transform.flatten().transforms: + if isinstance(_tr, RandomizableTrait) or not isinstance(_tr, Transform): + break + hashable_transforms.append(_tr) + # Try to hash. Fall back to a hash of their names + try: + transform_hash = hash_xform_func(hashable_transforms) + except TypeError as te: + if "is not JSON serializable" not in str(te): + raise te + names = "".join(tr.__class__.__name__ for tr in hashable_transforms) + transform_hash = hash_xform_func(names) + self.new_transform_hash = transform_hash.decode("utf-8") + + def _update_cache(self, orig_item_transformed, item_transformed): + """Combine data hash with new transform hash, then load the transformed cached data, or if not found: + transform with new transform, then save under the new hash.""" + hashfile = None + if self.cache_dir is not None: + new_data_item_md5 = self.hash_func(orig_item_transformed).decode("utf-8") + new_data_item_md5 += self.new_transform_hash + new_hashfile = self.cache_dir / f"{new_data_item_md5}.pt" + + if new_hashfile is not None and new_hashfile.is_file(): # cache hit + try: + return torch.load(new_hashfile) + except PermissionError as e: + if sys.platform != "win32": + raise e + + # transform with new transform + _item_transformed = self._pre_transform(item_transformed) + if new_hashfile is None: + return _item_transformed + try: + # NOTE: Writing to a temporary directory and then using a nearly atomic rename operation + # to make the cache more robust to manual killing of parent process + # which may leave partially written cache files in an incomplete state + with tempfile.TemporaryDirectory() as tmpdirname: + temp_hash_file = Path(tmpdirname) / new_hashfile.name + torch.save( + obj=_item_transformed, + f=temp_hash_file, + pickle_module=look_up_option(self.pickle_module, SUPPORTED_PICKLE_MOD), + pickle_protocol=self.pickle_protocol, + ) + if temp_hash_file.is_file() and not new_hashfile.is_file(): + # On Unix, if target exists and is a file, it will be replaced silently if the user has permission. + # for more details: https://docs.python.org/3/library/shutil.html#shutil.move. + try: + shutil.move(str(temp_hash_file), new_hashfile) + except FileExistsError: + pass + except PermissionError: # project-monai/monai issue #3613 + pass + return _item_transformed + + def _hash_exists(self, data): + hashfile = None + if self.cache_dir is not None: + new_data_item_md5 = self.hash_func(data).decode("utf-8") + new_data_item_md5 += self.new_transform_hash + hashfile = self.cache_dir / f"{new_data_item_md5}.pt" + + if hashfile is not None and hashfile.is_file(): # cache hit + return True + else: + return False + + def _transform(self, index: int): + + # check if new hashfile exists + if self.old_transform: + found_new_transform_hashfile = self._hash_exists(self.data[index]) + else: + found_new_transform_hashfile = False + + if found_new_transform_hashfile: # if the new hashfile was found, skip the old transforms completely + # the following line makes sure that the subsequent transforms are based on new_transform rather than old_transform + self.transform = self.new_transform + pre_random_item = self._update_cache(self.data[index], item_transformed=None) + else: # if the new hashfile was not found, the first transform has to be applied, or the corresponding hashfile loaded + # default behaviour of PersistentDataset (create and/or load first transform hashfile) + pre_random_item = self._cachecheck(self.data[index]) + if self.old_transform: # if an old_transform was provided, the new_transform has to update the pre_random_item (and save it in the cache) + # the following line makes sure that the subsequent transforms are based on new_transform rather than old_transform + self.transform = self.new_transform + # load the cached files and apply the new transform, then save the results with the new hash + pre_random_item = self._update_cache(self.data[index], pre_random_item) + + out = self._post_transform(pre_random_item) + + # make sure the transform is reset to the old_transform for the next iteration + if self.old_transform: + self.transform = self.old_transform + + return out + + class CacheNTransDataset(PersistentDataset): """ Extension of `PersistentDataset`, tt can also cache the result of first N transforms, no matter it's random or not. diff --git a/monai/data/utils.py b/monai/data/utils.py index 135a35f205..9e70da7732 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -32,6 +32,7 @@ from monai import config from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor, PathLike from monai.data.meta_obj import MetaObj +import monai # TODO: is there a better way to make Compose available here? from monai.transforms import Compose gives circular import error from monai.utils import ( MAX_SEED, BlendMode, @@ -80,6 +81,7 @@ "partition_dataset", "partition_dataset_classes", "pickle_hashing", + "pickle_hash_transform_names", "rectify_header_sform_qform", "reorient_spatial_axes", "resample_datalist", @@ -1384,6 +1386,26 @@ def pickle_hashing(item, protocol=pickle.HIGHEST_PROTOCOL) -> bytes: return f"{cache_key}".encode() +def pickle_hash_transform_names(hashable_transforms): + """ + hash function for transforms based only on the function names. + This will produce the same hash even if the transform code changes between runs as long as the transform names + stay the same therefore, it is required to delete the cache_dir between runs, if the transforms change. + The original pickle_hashing function is not suitable since some transform's (e.g. AffineD) instances change + their hash after it is applied once (but it is required that the hash remains unchanged) + + Args: + hashable_transforms: list of transforms whose names go into the pickle_hashing function + + Returns: the corresponding hash key + """ + if not isinstance(hashable_transforms, monai.transforms.Compose): + hashable_transforms = monai.transforms.Compose(hashable_transforms) + hashable_transforms = hashable_transforms.flatten().transforms + hash = pickle_hashing([h.__class__.__name__ for h in hashable_transforms]) + return hash + + def sorted_dict(item, key=None, reverse=False): """Return a new sorted dictionary from the `item`.""" if not isinstance(item, dict): diff --git a/tests/test_persistentstageddataset.py b/tests/test_persistentstageddataset.py new file mode 100644 index 0000000000..8a077640d2 --- /dev/null +++ b/tests/test_persistentstageddataset.py @@ -0,0 +1,203 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +import pickle +import tempfile +import unittest + +import nibabel as nib +import numpy as np +import torch.testing +from parameterized import parameterized + +from monai.data import PersistentStagedDataset, json_hashing +from monai.transforms import Compose, Flip, Identity, LoadImaged, SimulateDelayd, Transform + +TEST_CASE_1 = [ + Compose( + [ + LoadImaged(keys=["image", "label", "extra"]), + SimulateDelayd(keys=["image", "label", "extra"], delay_time=[1e-7, 1e-6, 1e-5]), + ] + ), + (128, 128, 128), +] + +TEST_CASE_2 = [ + [ + LoadImaged(keys=["image", "label", "extra"]), + SimulateDelayd(keys=["image", "label", "extra"], delay_time=[1e-7, 1e-6, 1e-5]), + ], + (128, 128, 128), +] + +TEST_CASE_3 = [None, (128, 128, 128)] + + +class _InplaceXform(Transform): + def __call__(self, data): + if data: + data[0] = data[0] + np.pi + else: + data.append(1) + return data + + +class TestDataset(unittest.TestCase): + def test_cache(self): + """testing no inplace change to the hashed item""" + items = [[list(range(i))] for i in range(5)] + + with tempfile.TemporaryDirectory() as tempdir: + ds = PersistentStagedDataset( + data=items, + new_transform=_InplaceXform(), + old_transform=None, + cache_dir=tempdir, + pickle_module="pickle", + pickle_protocol=pickle.HIGHEST_PROTOCOL, + ) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + ds1 = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir) + self.assertEqual(list(ds1), list(ds)) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + + ds = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + ds1 = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing) + self.assertEqual(list(ds1), list(ds)) + self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) + + @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) + def test_shape(self, transform, expected_shape): + test_image = nib.Nifti1Image(np.random.randint(0, 2, size=[128, 128, 128]).astype(float), np.eye(4)) + with tempfile.TemporaryDirectory() as tempdir: + nib.save(test_image, os.path.join(tempdir, "test_image1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_label1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_extra1.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_image2.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_label2.nii.gz")) + nib.save(test_image, os.path.join(tempdir, "test_extra2.nii.gz")) + test_data = [ + { + "image": os.path.join(tempdir, "test_image1.nii.gz"), + "label": os.path.join(tempdir, "test_label1.nii.gz"), + "extra": os.path.join(tempdir, "test_extra1.nii.gz"), + }, + { + "image": os.path.join(tempdir, "test_image2.nii.gz"), + "label": os.path.join(tempdir, "test_label2.nii.gz"), + "extra": os.path.join(tempdir, "test_extra2.nii.gz"), + }, + ] + + cache_dir = os.path.join(os.path.join(tempdir, "cache"), "data") + dataset_precached = PersistentStagedDataset(data=test_data, new_transform=transform, old_transform=None, + cache_dir=cache_dir) + data1_precached = dataset_precached[0] + data2_precached = dataset_precached[1] + + dataset_postcached = PersistentStagedDataset(data=test_data, new_transform=transform, old_transform=None, + cache_dir=cache_dir) + data1_postcached = dataset_postcached[0] + data2_postcached = dataset_postcached[1] + data3_postcached = dataset_postcached[0:2] + + if transform is None: + self.assertEqual(data1_precached["image"], os.path.join(tempdir, "test_image1.nii.gz")) + self.assertEqual(data2_precached["label"], os.path.join(tempdir, "test_label2.nii.gz")) + self.assertEqual(data1_postcached["image"], os.path.join(tempdir, "test_image1.nii.gz")) + self.assertEqual(data2_postcached["extra"], os.path.join(tempdir, "test_extra2.nii.gz")) + else: + self.assertTupleEqual(data1_precached["image"].shape, expected_shape) + self.assertTupleEqual(data1_precached["label"].shape, expected_shape) + self.assertTupleEqual(data1_precached["extra"].shape, expected_shape) + self.assertTupleEqual(data2_precached["image"].shape, expected_shape) + self.assertTupleEqual(data2_precached["label"].shape, expected_shape) + self.assertTupleEqual(data2_precached["extra"].shape, expected_shape) + + self.assertTupleEqual(data1_postcached["image"].shape, expected_shape) + self.assertTupleEqual(data1_postcached["label"].shape, expected_shape) + self.assertTupleEqual(data1_postcached["extra"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["image"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["label"].shape, expected_shape) + self.assertTupleEqual(data2_postcached["extra"].shape, expected_shape) + for d in data3_postcached: + self.assertTupleEqual(d["image"].shape, expected_shape) + + # update the data to cache + test_data_new = [ + { + "image": os.path.join(tempdir, "test_image1_new.nii.gz"), + "label": os.path.join(tempdir, "test_label1_new.nii.gz"), + "extra": os.path.join(tempdir, "test_extra1_new.nii.gz"), + }, + { + "image": os.path.join(tempdir, "test_image2_new.nii.gz"), + "label": os.path.join(tempdir, "test_label2_new.nii.gz"), + "extra": os.path.join(tempdir, "test_extra2_new.nii.gz"), + }, + ] + dataset_postcached.set_data(data=test_data_new) + # test new exchanged cache content + if transform is None: + self.assertEqual(dataset_postcached[0]["image"], os.path.join(tempdir, "test_image1_new.nii.gz")) + self.assertEqual(dataset_postcached[0]["label"], os.path.join(tempdir, "test_label1_new.nii.gz")) + self.assertEqual(dataset_postcached[1]["extra"], os.path.join(tempdir, "test_extra2_new.nii.gz")) + + def test_different_transforms(self): + """ + Different instances of `PersistentDataset` with the same cache_dir, + same input data, but different transforms should give different results. + """ + shape = (1, 10, 9, 8) + im = np.arange(0, np.prod(shape)).reshape(shape) + with tempfile.TemporaryDirectory() as path: + im1 = PersistentStagedDataset(data=[im], new_transform=Identity(), old_transform=None, cache_dir=path)[0] + im2 = PersistentStagedDataset(data=[im], new_transform=Flip(1), old_transform=None, cache_dir=path)[0] + l2 = ((im1 - im2) ** 2).sum() ** 0.5 + self.assertTrue(l2 > 1) + + class FlipCallCheck(Flip): + """wrapper for Flip transform that keeps track of how many times the transform was called""" + call_counter = 0 + + def __call__(self, *args, **kwargs): + self.call_counter += 1 + return super().__call__(*args, **kwargs) + + def test_old_transform(self): + """ + Different instances of `PersistentDataset` with the same cache_dir, + same input data, but different transforms should give different results. + """ + shape = (1, 2, 2, 2) + im = torch.arange(0, np.prod(shape)).reshape(shape) + flip_tfm = self.FlipCallCheck(1) + with tempfile.TemporaryDirectory() as path: + # step 1: cache im data transformed with new_transform (flip) + PersistentStagedDataset(data=[im], new_transform=flip_tfm, old_transform=None, cache_dir=path) + # step 2: apply new_transform (flip again) to cached data ([im]+old_transform points to cached data) + im2 = PersistentStagedDataset(data=[im], new_transform=flip_tfm, old_transform=flip_tfm, cache_dir=path)[0] + + # check output: flipping twice should return the original data + torch.testing.assert_close(im, im2) + + # make sure that the Flip transform was called only 2 times (once in step 1 and once in step 2, i.e., the + # cached data was found and loaded in step 2, and the old_transform was not applied again. + self.assertTrue(flip_tfm.call_counter == 2) + + +if __name__ == "__main__": + unittest.main() From 431cf0f2dad7daeca405374006f4194df65eb4f4 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Tue, 9 May 2023 11:39:21 +0100 Subject: [PATCH 16/24] Code formatting --- monai/data/__init__.py | 2 +- monai/data/dataset.py | 43 +-- monai/data/utils.py | 7 +- monai/networks/nets/dynunet.py | 2 +- monai/transforms/__init__.py | 10 +- monai/transforms/intensity/array.py | 42 ++- monai/transforms/intensity/dictionary.py | 40 +-- monai/transforms/post/dictionary.py | 1 - monai/transforms/spatial/array.py | 147 +++++----- monai/transforms/spatial/dictionary.py | 29 +- monai/transforms/utility/array.py | 10 +- monai/transforms/utility/dictionary.py | 6 +- tests/test_adjust_contrast.py | 8 +- tests/test_adjust_contrastd.py | 7 +- tests/test_persistentstageddataset.py | 23 +- tests/test_rand_affine.py | 42 +-- tests/test_rand_affine_grid.py | 297 ++++++++++---------- tests/test_rand_affined.py | 72 ++--- tests/test_rand_simulate_low_resolution.py | 86 +++--- tests/test_rand_simulate_low_resolutiond.py | 56 ++-- tests/test_sample_foreground_locations.py | 10 +- tests/test_sample_foreground_locationsd.py | 22 +- tests/test_scale_intensity_fixed_mean.py | 15 +- 23 files changed, 489 insertions(+), 488 deletions(-) diff --git a/monai/data/__init__.py b/monai/data/__init__.py index 06255cbe52..db4a9378fa 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -100,8 +100,8 @@ pad_list_data_collate, partition_dataset, partition_dataset_classes, - pickle_hashing, pickle_hash_transform_names, + pickle_hashing, rectify_header_sform_qform, remove_extra_metadata, remove_keys, diff --git a/monai/data/dataset.py b/monai/data/dataset.py index c4ac793a8e..b5153a754f 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -34,7 +34,7 @@ from torch.utils.data import Dataset as _TorchDataset from torch.utils.data import Subset -from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hashing, pickle_hash_transform_names +from monai.data.utils import SUPPORTED_PICKLE_MOD, convert_tables_to_dicts, pickle_hash_transform_names, pickle_hashing from monai.transforms import ( Compose, Randomizable, @@ -419,28 +419,29 @@ def _transform(self, index: int): class PersistentStagedDataset(PersistentDataset): def __init__( - self, - new_transform: Sequence[Callable] | Callable, - old_transform: Sequence[Callable] | Callable, - data: Sequence, - cache_dir: Path | str | None, - hash_func: Callable[..., bytes] = pickle_hashing, - pickle_module: str = "pickle", - pickle_protocol: int = DEFAULT_PROTOCOL, - hash_transform: Callable[..., bytes] | None = pickle_hash_transform_names, - reset_ops_id: bool = True, + self, + new_transform: Sequence[Callable] | Callable, + old_transform: Sequence[Callable] | Callable, + data: Sequence, + cache_dir: Path | str | None, + hash_func: Callable[..., bytes] = pickle_hashing, + pickle_module: str = "pickle", + pickle_protocol: int = DEFAULT_PROTOCOL, + hash_transform: Callable[..., bytes] | None = pickle_hash_transform_names, + reset_ops_id: bool = True, ) -> None: - self.old_transform = old_transform if not self.old_transform: # if no old_transform is passed, create a normal PersistentDataset based on the new_transform - super().__init__(data, new_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, - reset_ops_id) + super().__init__( + data, new_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, reset_ops_id + ) else: # if an old_transform is passed, create a normal PersistentDataset based on the old_transform - super().__init__(data, old_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, - reset_ops_id) + super().__init__( + data, old_transform, cache_dir, hash_func, pickle_module, pickle_protocol, hash_transform, reset_ops_id + ) self.new_transform = new_transform if not isinstance(self.new_transform, Compose): self.new_transform = Compose(new_transform) @@ -455,7 +456,7 @@ def set_combined_transform_hash(self, hash_xform_func: Callable[..., bytes]): at the first non-deterministic transform, or first that does not inherit from MONAI's `Transform` class.""" hashable_transforms = [] - for _tr in self.transform.flatten().transforms+self.new_transform.flatten().transforms: + for _tr in self.transform.flatten().transforms + self.new_transform.flatten().transforms: if isinstance(_tr, RandomizableTrait) or not isinstance(_tr, Transform): break hashable_transforms.append(_tr) @@ -471,8 +472,7 @@ def set_combined_transform_hash(self, hash_xform_func: Callable[..., bytes]): def _update_cache(self, orig_item_transformed, item_transformed): """Combine data hash with new transform hash, then load the transformed cached data, or if not found: - transform with new transform, then save under the new hash.""" - hashfile = None + transform with new transform, then save under the new hash.""" if self.cache_dir is not None: new_data_item_md5 = self.hash_func(orig_item_transformed).decode("utf-8") new_data_item_md5 += self.new_transform_hash @@ -525,7 +525,6 @@ def _hash_exists(self, data): return False def _transform(self, index: int): - # check if new hashfile exists if self.old_transform: found_new_transform_hashfile = self._hash_exists(self.data[index]) @@ -539,7 +538,9 @@ def _transform(self, index: int): else: # if the new hashfile was not found, the first transform has to be applied, or the corresponding hashfile loaded # default behaviour of PersistentDataset (create and/or load first transform hashfile) pre_random_item = self._cachecheck(self.data[index]) - if self.old_transform: # if an old_transform was provided, the new_transform has to update the pre_random_item (and save it in the cache) + if ( + self.old_transform + ): # if an old_transform was provided, the new_transform has to update the pre_random_item (and save it in the cache) # the following line makes sure that the subsequent transforms are based on new_transform rather than old_transform self.transform = self.new_transform # load the cached files and apply the new transform, then save the results with the new hash diff --git a/monai/data/utils.py b/monai/data/utils.py index 9e70da7732..e43ef8dff3 100644 --- a/monai/data/utils.py +++ b/monai/data/utils.py @@ -32,7 +32,6 @@ from monai import config from monai.config.type_definitions import NdarrayOrTensor, NdarrayTensor, PathLike from monai.data.meta_obj import MetaObj -import monai # TODO: is there a better way to make Compose available here? from monai.transforms import Compose gives circular import error from monai.utils import ( MAX_SEED, BlendMode, @@ -1399,8 +1398,10 @@ def pickle_hash_transform_names(hashable_transforms): Returns: the corresponding hash key """ - if not isinstance(hashable_transforms, monai.transforms.Compose): - hashable_transforms = monai.transforms.Compose(hashable_transforms) + from monai.transforms import Compose # needs to be here to avoid circular import + + if not isinstance(hashable_transforms, Compose): + hashable_transforms = Compose(hashable_transforms) hashable_transforms = hashable_transforms.flatten().transforms hash = pickle_hashing([h.__class__.__name__ for h in hashable_transforms]) return hash diff --git a/monai/networks/nets/dynunet.py b/monai/networks/nets/dynunet.py index c4594b7137..2955eb4ec6 100644 --- a/monai/networks/nets/dynunet.py +++ b/monai/networks/nets/dynunet.py @@ -273,7 +273,7 @@ def check_filters(self): def forward(self, x): out = self.skip_layers(x) out = self.output_block(out) - if self.training and self.deep_supervision: + if self.training and self.deep_supervision and not self.return_list: out_all = torch.zeros(out.shape[0], len(self.heads) + 1, *out.shape[1:], device=out.device, dtype=out.dtype) out_all[:, 0] = out for idx, feature_map in enumerate(self.heads): diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 98d4c64832..053f400fa3 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -382,6 +382,7 @@ RandGridPatch, RandRotate, RandRotate90, + RandSimulateLowResolution, RandZoom, Resample, ResampleToMatch, @@ -389,7 +390,6 @@ Rotate, Rotate90, Spacing, - RandSimulateLowResolution, SpatialResample, Zoom, ) @@ -439,6 +439,9 @@ RandRotated, RandRotateD, RandRotateDict, + RandSimulateLowResolutiond, + RandSimulateLowResolutionD, + RandSimulateLowResolutionDict, RandZoomd, RandZoomD, RandZoomDict, @@ -457,9 +460,6 @@ Spacingd, SpacingD, SpacingDict, - RandSimulateLowResolutiond, - RandSimulateLowResolutionD, - RandSimulateLowResolutionDict, SpatialResampled, SpatialResampleD, SpatialResampleDict, @@ -496,8 +496,8 @@ RandLambda, RemoveRepeatedChannel, RepeatChannel, - SimulateDelay, SampleForegroundLocations, + SimulateDelay, SplitChannel, SplitDim, SqueezeDim, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 3055910872..d7100e2675 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -571,13 +571,13 @@ class RandScaleIntensityFixedMean(RandomizableTransform): backend = ScaleIntensityFixedMean.backend def __init__( - self, - prob: float = 0.1, - factors: Sequence[float] | float = 0, - fixed_mean: bool = True, - preserve_range: bool = False, - dtype: DtypeLike = np.float32 - ) -> None: + self, + prob: float = 0.1, + factors: Sequence[float] | float = 0, + fixed_mean: bool = True, + preserve_range: bool = False, + dtype: DtypeLike = np.float32, + ) -> None: """ Args: factors: factor range to randomly scale by ``v = v * (1 + factor)``. @@ -620,8 +620,9 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if not self._do_transform: return convert_data_type(img, dtype=self.dtype)[0] - return ScaleIntensityFixedMean(factor=self.factor, fixed_mean=self.fixed_mean, - preserve_range=self.preserve_range, dtype=self.dtype)(img) + return ScaleIntensityFixedMean( + factor=self.factor, fixed_mean=self.fixed_mean, preserve_range=self.preserve_range, dtype=self.dtype + )(img) class RandScaleIntensity(RandomizableTransform): @@ -971,11 +972,7 @@ class AdjustContrast(Transform): backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, - gamma: float, - invert_image: bool = False, - retain_stats: bool = False) -> None: - + def __init__(self, gamma: float, invert_image: bool = False, retain_stats: bool = False) -> None: if not isinstance(gamma, (int, float)): raise ValueError(f"gamma must be a float or int number, got {type(gamma)} {gamma}.") self.gamma = gamma @@ -1002,10 +999,10 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: if self.retain_stats: # zero mean and normalize - ret = (ret - ret.mean()) + ret = ret - ret.mean() ret = ret / (ret.std() + 1e-8) # restore old mean and standard deviation - ret = sd*ret + mn + ret = sd * ret + mn if self.invert_image: ret = -ret @@ -1031,12 +1028,13 @@ class RandAdjustContrast(RandomizableTransform): backend = AdjustContrast.backend - def __init__(self, - prob: float = 0.1, - gamma: Sequence[float] | float = (0.5, 4.5), - invert_image: bool = False, - retain_stats: bool = False,) \ - -> None: + def __init__( + self, + prob: float = 0.1, + gamma: Sequence[float] | float = (0.5, 4.5), + invert_image: bool = False, + retain_stats: bool = False, + ) -> None: RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index c2ebe19c8c..a44a196369 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -48,6 +48,7 @@ RandKSpaceSpikeNoise, RandRicianNoise, RandScaleIntensity, + RandScaleIntensityFixedMean, RandShiftIntensity, RandStdShiftIntensity, SavitzkyGolaySmooth, @@ -56,7 +57,7 @@ ScaleIntensityRangePercentiles, ShiftIntensity, StdShiftIntensity, - ThresholdIntensity, RandScaleIntensityFixedMean, + ThresholdIntensity, ) from monai.transforms.transform import MapTransform, RandomizableTransform from monai.transforms.utils import is_positive @@ -636,14 +637,14 @@ class RandScaleIntensityFixedMeand(RandomizableTransform, MapTransform): backend = RandScaleIntensityFixedMean.backend def __init__( - self, - keys: KeysCollection, - factors: Sequence[float, float] | float, - fixed_mean: bool = True, - preserve_range: bool = False, - prob: float = 0.1, - dtype: DtypeLike = np.float32, - allow_missing_keys: bool = False, + self, + keys: KeysCollection, + factors: Sequence[float, float] | float, + fixed_mean: bool = True, + preserve_range: bool = False, + prob: float = 0.1, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, ) -> None: """ Args: @@ -665,11 +666,12 @@ def __init__( RandomizableTransform.__init__(self, prob) self.fixed_mean = fixed_mean self.preserve_range = preserve_range - self.scaler = RandScaleIntensityFixedMean(factors=factors, fixed_mean=self.fixed_mean, - preserve_range=preserve_range, dtype=dtype, prob=1.0) + self.scaler = RandScaleIntensityFixedMean( + factors=factors, fixed_mean=self.fixed_mean, preserve_range=preserve_range, dtype=dtype, prob=1.0 + ) def set_random_state( - self, seed: int | None = None, state: np.random.RandomState | None = None + self, seed: int | None = None, state: np.random.RandomState | None = None ) -> "RandScaleIntensityFixedMean": super().set_random_state(seed, state) self.scaler.set_random_state(seed, state) @@ -882,12 +884,14 @@ class AdjustContrastd(MapTransform): backend = AdjustContrast.backend - def __init__(self, - keys: KeysCollection, - gamma: float, - invert_image: bool = False, - retain_stats: bool = False, - allow_missing_keys: bool = False) -> None: + def __init__( + self, + keys: KeysCollection, + gamma: float, + invert_image: bool = False, + retain_stats: bool = False, + allow_missing_keys: bool = False, + ) -> None: super().__init__(keys, allow_missing_keys) self.adjuster = AdjustContrast(gamma, invert_image, retain_stats) diff --git a/monai/transforms/post/dictionary.py b/monai/transforms/post/dictionary.py index ace6c6d718..2ba1f4e00c 100644 --- a/monai/transforms/post/dictionary.py +++ b/monai/transforms/post/dictionary.py @@ -163,7 +163,6 @@ def __init__(self, keys: KeysCollection, downsampled_shapes, allow_missing_keys: See also: :py:class:`monai.transforms.compose.MapTransform` downsampled_shapes: List of shapes of the downsampled tensors/arrays allow_missing_keys: don't raise exception if key is missing. - """ self.append_downsampled = AppendDownsampled(downsampled_shapes) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 8c6b67c422..177228b839 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1623,53 +1623,53 @@ def __init__( dtype: DtypeLike = np.float32, ) -> None: """ - rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then - `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. - This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be - in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` - for dim0 and nothing for the remaining dimensions. - prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. - shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select - shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, - take a 3D affine as example:: - - [ - [1.0, params[0], params[1], 0.0], - [params[2], 1.0, params[3], 0.0], - [params[4], params[5], 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. - translate_range: translate range with format matching `rotate_range`, it defines the range to randomly - select voxels to translate for every spatial dims. - prob_translate: probability to perform a random translation. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. - scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select - the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1.0). - prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. - foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground - location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of - a translation to a randomly selected sample of the list of foreground locations and a further random - translation based on prob_translate and translate_range. Final translation parameters are clipped to a - valid range which is defined such that for each spatial dimension the center of the grid cannot be - closer than half the grid size to the corner of the input image. In the case in which a translation to - a foreground location is not required, the translation parameters will be sampled uniformly within the - valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range - clipping is applied. - device: device to store the output grid data. - dtype: data type for the grid computation. Defaults to ``np.float32``. - If ``None``, use the data type of input data (if `grid` is provided). - - See also: - - :py:meth:`monai.transforms.utils.create_rotate` - - :py:meth:`monai.transforms.utils.create_shear` - - :py:meth:`monai.transforms.utils.create_translate` - - :py:meth:`monai.transforms.utils.create_scale` + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, + take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select voxels to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. + device: device to store the output grid data. + dtype: data type for the grid computation. Defaults to ``np.float32``. + If ``None``, use the data type of input data (if `grid` is provided). + + See also: + - :py:meth:`monai.transforms.utils.create_rotate` + - :py:meth:`monai.transforms.utils.create_shear` + - :py:meth:`monai.transforms.utils.create_translate` + - :py:meth:`monai.transforms.utils.create_scale` """ self.rotate_range = ensure_tuple(rotate_range) @@ -1756,7 +1756,6 @@ def __call__( # if the foreground_oversampling_prob argument is used, the translation is determined by whether a foreground # location should initially be targeted, furthermore, any translation will be clipped to a "valid" range if self.foreground_oversampling_prob is not None: - # define the margin at the image borders (where the center point is not supposed to end up) by half the patch size patch_size = np.array(grid.shape[1:] if grid is not None else spatial_size) if self.scale_params is not None: @@ -1769,11 +1768,12 @@ def __call__( # in that case no translation should happen, so set those values to 0 max_transl = np.array([max(tr, 0) for tr in max_transl]) - if self.translate_to_foreground: # first translate to foreground pixel, then add the random translation, then clip to valid range - + if ( + self.translate_to_foreground + ): # first translate to foreground pixel, then add the random translation, then clip to valid range # randomly pick one of the previously sampled foreground pixels to translate the center point of the grid to # select one fg sample based on float randomized in randomize function - rand_int = int(np.round(self.rand_float_to_pick_fg_location*(len(fg_indices)-1))) + rand_int = int(np.round(self.rand_float_to_pick_fg_location * (len(fg_indices) - 1))) random_fg_index = fg_indices[rand_int][1:] # from this, calculate a translation to the fg point of a grid which is initially located at the image center @@ -1781,20 +1781,23 @@ def __call__( # add the additional random translation which was randomly selected from the translate_range if self.translate_params is not None and self.translate_params != []: - assert(len(translate_params_to_fg_point) == len(self.translate_params)) - translate_params = [t_fg + t_range for t_fg, t_range - in zip(translate_params_to_fg_point, self.translate_params)] + assert len(translate_params_to_fg_point) == len(self.translate_params) + translate_params = [ + t_fg + t_range for t_fg, t_range in zip(translate_params_to_fg_point, self.translate_params) + ] else: translate_params = translate_params_to_fg_point else: # simply translate randomly within valid range (in this case, original translate_params are discarded) # for each dimension, self.rand_norm_translate_params was randomly sampled between -1 and 1, # now scale this value to the full range - translate_params = [max_transl[i]*self.rand_norm_translate_params[i] for i in range(len(max_transl))] + translate_params = [max_transl[i] * self.rand_norm_translate_params[i] for i in range(len(max_transl))] # if the current translation parameters exceed the max_transl, use max_transl in the corresponding direction instead - clipped_translate_params = [t_fg if abs(t_fg) <= t_max else t_fg / abs(t_fg) * t_max - for t_fg, t_max in zip(translate_params, max_transl)] + clipped_translate_params = [ + t_fg if abs(t_fg) <= t_max else t_fg / abs(t_fg) * t_max + for t_fg, t_max in zip(translate_params, max_transl) + ] self.translate_params = list(clipped_translate_params) @@ -3394,11 +3397,7 @@ def randomize(self, data: Optional[Any] = None) -> None: if not self._do_transform: return None - def __call__( - self, - img: torch.Tensor, - randomize: bool = True, - ) -> torch.Tensor: + def __call__(self, img: torch.Tensor, randomize: bool = True) -> torch.Tensor: """ Args: img: shape must be (num_channels, H, W[, D]), @@ -3407,22 +3406,20 @@ def __call__( self.randomize() if self._do_transform: - input_shape = np.array(img.shape[1:]) target_shape = np.round(input_shape * self.zoom_factor).astype(np.int_) - resize_tfm_downsample = Resize(spatial_size=target_shape, - size_mode='all', - mode=self.downsample_mode, - anti_aliasing=False, - ) - - resize_tfm_upsample = Resize(spatial_size=input_shape, - size_mode='all', - mode=self.upsample_mode, - anti_aliasing=False, - align_corners=self.align_corners - ) + resize_tfm_downsample = Resize( + spatial_size=target_shape, size_mode="all", mode=self.downsample_mode, anti_aliasing=False + ) + + resize_tfm_upsample = Resize( + spatial_size=input_shape, + size_mode="all", + mode=self.upsample_mode, + anti_aliasing=False, + align_corners=self.align_corners, + ) # temporarily disable metadata tracking, since we do not want to invert the two Resize functions in post-processing original_tack_meta_value = get_track_meta() set_track_meta(False) diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py index 304acb10de..8b48d29012 100644 --- a/monai/transforms/spatial/dictionary.py +++ b/monai/transforms/spatial/dictionary.py @@ -968,8 +968,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N self.rand_affine.randomize() item = d[first_key] - spatial_size = d[first_key].shape[1:] - # item.peek_pending_shape() if isinstance(item, MetaTensor) else item.shape[1:] + spatial_size = item.peek_pending_shape() if isinstance(item, MetaTensor) else item.shape[1:] sp_size = fall_back_tuple(self.rand_affine.spatial_size, spatial_size) # change image size or do random transform @@ -980,14 +979,13 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N grid = self.rand_affine.get_identity_grid(sp_size) if self._do_transform: # add some random factors if self.foreground_oversampling_prob is not None: - fg_indices = d[self.label_key_for_foreground_oversampling].meta['foreground_sample_locations'] + fg_indices = d[self.label_key_for_foreground_oversampling].meta["foreground_sample_locations"] else: fg_indices = None - grid = self.rand_affine.rand_affine_grid(spatial_size=sp_size, - grid=grid, - image_size=spatial_size, - fg_indices=fg_indices) + grid = self.rand_affine.rand_affine_grid( + spatial_size=sp_size, grid=grid, image_size=spatial_size, fg_indices=fg_indices + ) for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode): # do the transform @@ -2199,12 +2197,15 @@ def __init__( self.align_corners = align_corners self.device = device - self.sim_lowres_tfm = RandSimulateLowResolution(prob=1.0, # probability is handled by dictionary class - downsample_mode=self.downsample_mode, - upsample_mode=self.upsample_mode, - zoom_range=self.zoom_range, - align_corners=self.align_corners, - device=self.device) + self.sim_lowres_tfm = RandSimulateLowResolution( + prob=1.0, # probability is handled by dictionary class + downsample_mode=self.downsample_mode, + upsample_mode=self.upsample_mode, + zoom_range=self.zoom_range, + align_corners=self.align_corners, + device=self.device, + ) + def set_random_state( self, seed: int | None = None, state: np.random.RandomState | None = None ) -> "RandSimulateLowResolutiond": @@ -2220,7 +2221,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N self.randomize(None) - for key, downsample_mode, upsample_mode in self.key_iterator(d, self.downsample_mode, self.upsample_mode): + for key in self.key_iterator(d): # do the transform if self._do_transform: d[key] = self.sim_lowres_tfm(d[key]) # type: ignore diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 7b771ad808..9d0187cc38 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -995,11 +995,8 @@ class SampleForegroundLocations(Transform): Sample foreground locations and store in metadata of the label. The locations can be used for foreground oversampling. """ - def __init__( - self, - num_samples: int = 1000, - dtype: DtypeLike = np.float32, - ) -> None: + + def __init__(self, num_samples: int = 1000, dtype: DtypeLike = np.float32) -> None: """ Args: num_samples: number of foreground samples @@ -1017,12 +1014,13 @@ def __call__(self, label: NdarrayOrTensor) -> NdarrayOrTensor: all_locations = (label > 0).nonzero() # TODO: Handle case of no foreground - random_indices = torch.randint(0, len(all_locations), (self.num_samples, )) + random_indices = torch.randint(0, len(all_locations), (self.num_samples,)) random_samples = all_locations[random_indices] label.meta["foreground_sample_locations"] = random_samples.cpu().numpy() return label + class FgBgToIndices(Transform, MultiSampleTrait): """ Compute foreground and background of the input label data, return the indices. diff --git a/monai/transforms/utility/dictionary.py b/monai/transforms/utility/dictionary.py index 19b1ce454c..81778b830a 100644 --- a/monai/transforms/utility/dictionary.py +++ b/monai/transforms/utility/dictionary.py @@ -54,6 +54,7 @@ MapLabelValue, RemoveRepeatedChannel, RepeatChannel, + SampleForegroundLocations, SimulateDelay, SplitDim, SqueezeDim, @@ -63,7 +64,7 @@ ToPIL, TorchVision, ToTensor, - Transpose, SampleForegroundLocations, + Transpose, ) from monai.transforms.utils import extreme_points_to_image, get_extreme_points from monai.transforms.utils_pytorch_numpy_unification import concatenate @@ -1258,6 +1259,7 @@ class SampleForegroundLocationsd(MapTransform): """ Dictionary-based version :py:class:`monai.transforms.SampleForegroundLocations`. """ + def __init__( self, label_keys: KeysCollection, @@ -1278,7 +1280,7 @@ def __init__( self.dtype = dtype self.sample_foreground_locations = SampleForegroundLocations(num_samples=num_samples, dtype=self.dtype) - def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: d = dict(data) for key in self.key_iterator(d): diff --git a/tests/test_adjust_contrast.py b/tests/test_adjust_contrast.py index 4b2c718e49..9fa0247115 100644 --- a/tests/test_adjust_contrast.py +++ b/tests/test_adjust_contrast.py @@ -26,10 +26,7 @@ TEST_CASE_2 = [0.5, invert_image, retain_stats] TEST_CASE_3 = [4.5, invert_image, retain_stats] - TESTS.extend([TEST_CASE_1, - TEST_CASE_2, - TEST_CASE_3, - ]) + TESTS.extend([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) class TestAdjustContrast(NumpyImageTestCase2D): @@ -43,7 +40,6 @@ def test_correct_results(self, gamma, invert_image, retain_stats): if False: # gamma == 1.0: expected = self.imt else: - if invert_image: self.imt = -self.imt @@ -59,7 +55,7 @@ def test_correct_results(self, gamma, invert_image, retain_stats): if retain_stats: # zero mean and normalize - expected = (expected - expected.mean()) + expected = expected - expected.mean() expected = expected / (expected.std() + 1e-8) # restore old mean and standard deviation expected = sd * expected + mn diff --git a/tests/test_adjust_contrastd.py b/tests/test_adjust_contrastd.py index 9d3435394d..4a671ef7be 100644 --- a/tests/test_adjust_contrastd.py +++ b/tests/test_adjust_contrastd.py @@ -26,10 +26,7 @@ TEST_CASE_2 = [0.5, invert_image, retain_stats] TEST_CASE_3 = [4.5, invert_image, retain_stats] - TESTS.extend([TEST_CASE_1, - TEST_CASE_2, - TEST_CASE_3, - ]) + TESTS.extend([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) class TestAdjustContrastd(NumpyImageTestCase2D): @@ -53,7 +50,7 @@ def test_correct_results(self, gamma, invert_image, retain_stats): if retain_stats: # zero mean and normalize - expected = (expected - expected.mean()) + expected = expected - expected.mean() expected = expected / (expected.std() + 1e-8) # restore old mean and standard deviation expected = sd * expected + mn diff --git a/tests/test_persistentstageddataset.py b/tests/test_persistentstageddataset.py index 8a077640d2..902bbfec66 100644 --- a/tests/test_persistentstageddataset.py +++ b/tests/test_persistentstageddataset.py @@ -69,13 +69,19 @@ def test_cache(self): pickle_protocol=pickle.HIGHEST_PROTOCOL, ) self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) - ds1 = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir) + ds1 = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir + ) self.assertEqual(list(ds1), list(ds)) self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) - ds = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing) + ds = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing + ) self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) - ds1 = PersistentStagedDataset(data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing) + ds1 = PersistentStagedDataset( + data=items, new_transform=_InplaceXform(), old_transform=None, cache_dir=tempdir, hash_func=json_hashing + ) self.assertEqual(list(ds1), list(ds)) self.assertEqual(items, [[[]], [[0]], [[0, 1]], [[0, 1, 2]], [[0, 1, 2, 3]]]) @@ -103,13 +109,15 @@ def test_shape(self, transform, expected_shape): ] cache_dir = os.path.join(os.path.join(tempdir, "cache"), "data") - dataset_precached = PersistentStagedDataset(data=test_data, new_transform=transform, old_transform=None, - cache_dir=cache_dir) + dataset_precached = PersistentStagedDataset( + data=test_data, new_transform=transform, old_transform=None, cache_dir=cache_dir + ) data1_precached = dataset_precached[0] data2_precached = dataset_precached[1] - dataset_postcached = PersistentStagedDataset(data=test_data, new_transform=transform, old_transform=None, - cache_dir=cache_dir) + dataset_postcached = PersistentStagedDataset( + data=test_data, new_transform=transform, old_transform=None, cache_dir=cache_dir + ) data1_postcached = dataset_postcached[0] data2_postcached = dataset_postcached[1] data3_postcached = dataset_postcached[0:2] @@ -171,6 +179,7 @@ def test_different_transforms(self): class FlipCallCheck(Flip): """wrapper for Flip transform that keeps track of how many times the transform was called""" + call_counter = 0 def __call__(self, *args, **kwargs): diff --git a/tests/test_rand_affine.py b/tests/test_rand_affine.py index 7516958acb..1cccccfb34 100644 --- a/tests/test_rand_affine.py +++ b/tests/test_rand_affine.py @@ -101,9 +101,9 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8))), "spatial_size": (3, 3)}, p( - torch.tensor([[[32.1092, 22.3571, 12.6049], - [38.1935, 28.4413, 18.6892], - [44.2777, 34.5256, 24.7735]]]) + torch.tensor( + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] + ) ), ] ) @@ -121,9 +121,9 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8)))}, p( - torch.tensor([[[32.1092, 22.3571, 12.6049], - [38.1935, 28.4413, 18.6892], - [44.2777, 34.5256, 24.7735]]]) + torch.tensor( + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] + ) ), ] ) @@ -145,9 +145,9 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8)))}, p( - torch.tensor([[[32.1092, 22.3571, 12.6049], - [38.1935, 28.4413, 18.6892], - [44.2777, 34.5256, 24.7735]]]) + torch.tensor( + [[[32.1092, 22.3571, 12.6049], [38.1935, 28.4413, 18.6892], [44.2777, 34.5256, 24.7735]]] + ) ), ] ) @@ -183,9 +183,15 @@ ), {"img": p(torch.arange(64).reshape((1, 8, 8)))}, p( - torch.tensor([[[1.100917, 3.219612, 9.162791], - [7.743001, 13.68618, 19.629358], - [18.209568, 24.152748, 30.095926]]]) + torch.tensor( + [ + [ + [1.100917, 3.219612, 9.162791], + [7.743001, 13.68618, 19.629358], + [18.209568, 24.152748, 30.095926], + ] + ] + ) ), ] ) @@ -249,14 +255,16 @@ def test_no_randomize(self, initial_randomize, cache_grid): assert_allclose(arr1, arr2) @parameterized.expand(TEST_FOREGROUND_OVERSAMPLING) - def test_rand_affine(self, input_param, input_data, expected_val): + def test_rand_foreground_oversampling(self, input_param, input_data, expected_val): g = RandAffine(**input_param) g.set_random_state(123) - grid = g.rand_affine_grid(spatial_size=input_param['spatial_size'], - grid=None, - image_size=input_data['img'].shape[1:], - fg_indices=[[1, 2], [2, 2]]) + grid = g.rand_affine_grid( + spatial_size=input_param["spatial_size"], + grid=None, + image_size=input_data["img"].shape[1:], + fg_indices=[[1, 2], [2, 2]], + ) result = g(**input_data, grid=grid) test_resampler_lazy(g, result, input_param, input_data, seed=123) diff --git a/tests/test_rand_affine_grid.py b/tests/test_rand_affine_grid.py index ca62e785ec..ddcc7582ad 100644 --- a/tests/test_rand_affine_grid.py +++ b/tests/test_rand_affine_grid.py @@ -32,17 +32,11 @@ {"grid": p(torch.arange(0, 27).reshape((3, 3, 3)))}, p( np.array( - [[[17.7142, 19.8156, 21.9171], - [24.0185, 26.1199, 28.2214], - [30.3228, 32.4242, 34.5257]], - - [[58.8789, 62.1901, 65.5013], - [68.8125, 72.1238, 75.4350], - [78.7462, 82.0574, 85.3686]], - - [[18.0000, 19.0000, 20.0000], - [21.0000, 22.0000, 23.0000], - [24.0000, 25.0000, 26.0000]]] + [ + [[17.7142, 19.8156, 21.9171], [24.0185, 26.1199, 28.2214], [30.3228, 32.4242, 34.5257]], + [[58.8789, 62.1901, 65.5013], [68.8125, 72.1238, 75.4350], [78.7462, 82.0574, 85.3686]], + [[18.0000, 19.0000, 20.0000], [21.0000, 22.0000, 23.0000], [24.0000, 25.0000, 26.0000]], + ] ) ), ] @@ -52,53 +46,28 @@ {"translate_range": (3, 3, 3), "device": device}, {"spatial_size": (3, 3, 3)}, np.array( - [[[[-0.6921, -0.6921, -0.6921], - [-0.6921, -0.6921, -0.6921], - [-0.6921, -0.6921, -0.6921]], - - [[0.3079, 0.3079, 0.3079], - [0.3079, 0.3079, 0.3079], - [0.3079, 0.3079, 0.3079]], - - [[1.3079, 1.3079, 1.3079], - [1.3079, 1.3079, 1.3079], - [1.3079, 1.3079, 1.3079]]], - - [[[0.3168, 0.3168, 0.3168], - [1.3168, 1.3168, 1.3168], - [2.3168, 2.3168, 2.3168]], - - [[0.3168, 0.3168, 0.3168], - [1.3168, 1.3168, 1.3168], - [2.3168, 2.3168, 2.3168]], - - [[0.3168, 0.3168, 0.3168], - [1.3168, 1.3168, 1.3168], - [2.3168, 2.3168, 2.3168]]], - - [[[-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386]], - - [[-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386]], - - [[-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386], - [-1.4614, -0.4614, 0.5386]]], - - [[[1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000]], - - [[1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000]], - - [[1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000], - [1.0000, 1.0000, 1.0000]]]] + [ + [ + [[-0.6921, -0.6921, -0.6921], [-0.6921, -0.6921, -0.6921], [-0.6921, -0.6921, -0.6921]], + [[0.3079, 0.3079, 0.3079], [0.3079, 0.3079, 0.3079], [0.3079, 0.3079, 0.3079]], + [[1.3079, 1.3079, 1.3079], [1.3079, 1.3079, 1.3079], [1.3079, 1.3079, 1.3079]], + ], + [ + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], + [[0.3168, 0.3168, 0.3168], [1.3168, 1.3168, 1.3168], [2.3168, 2.3168, 2.3168]], + ], + [ + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], + [[-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386], [-1.4614, -0.4614, 0.5386]], + ], + [ + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], + [[1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000], [1.0000, 1.0000, 1.0000]], + ], + ] ), ] ) @@ -108,111 +77,127 @@ {"grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3)))}, p( np.array( - [[[[-30.7709, -30.5800, -30.3891], - [-30.1981, -30.0072, -29.8163], - [-29.6254, -29.4344, -29.2435]], - - [[-29.0526, -28.8617, -28.6707], - [-28.4798, -28.2889, -28.0980], - [-27.9070, -27.7161, -27.5252]], - - [[-27.3343, -27.1433, -26.9524], - [-26.7615, -26.5706, -26.3796], - [-26.1887, -25.9978, -25.8069]]], - - [[[42.8536, 44.3798, 45.9061], - [47.4324, 48.9586, 50.4849], - [52.0111, 53.5374, 55.0636]], - - [[56.5899, 58.1161, 59.6424], - [61.1686, 62.6949, 64.2211], - [65.7474, 67.2736, 68.7999]], - - [[70.3261, 71.8524, 73.3786], - [74.9049, 76.4312, 77.9574], - [79.4837, 81.0099, 82.5362]]], - - [[[29.3580, 30.0760, 30.7941], - [31.5121, 32.2301, 32.9481], - [33.6661, 34.3842, 35.1022]], - - [[35.8202, 36.5382, 37.2563], - [37.9743, 38.6923, 39.4103], - [40.1283, 40.8464, 41.5644]], - - [[42.2824, 43.0004, 43.7184], - [44.4365, 45.1545, 45.8725], - [46.5905, 47.3085, 48.0266]]], - - [[[81.0000, 82.0000, 83.0000], - [84.0000, 85.0000, 86.0000], - [87.0000, 88.0000, 89.0000]], - - [[90.0000, 91.0000, 92.0000], - [93.0000, 94.0000, 95.0000], - [96.0000, 97.0000, 98.0000]], - - [[99.0000, 100.0000, 101.0000], - [102.0000, 103.0000, 104.0000], - [105.0000, 106.0000, 107.0000]]]] + [ + [ + [ + [-30.7709, -30.5800, -30.3891], + [-30.1981, -30.0072, -29.8163], + [-29.6254, -29.4344, -29.2435], + ], + [ + [-29.0526, -28.8617, -28.6707], + [-28.4798, -28.2889, -28.0980], + [-27.9070, -27.7161, -27.5252], + ], + [ + [-27.3343, -27.1433, -26.9524], + [-26.7615, -26.5706, -26.3796], + [-26.1887, -25.9978, -25.8069], + ], + ], + [ + [[42.8536, 44.3798, 45.9061], [47.4324, 48.9586, 50.4849], [52.0111, 53.5374, 55.0636]], + [[56.5899, 58.1161, 59.6424], [61.1686, 62.6949, 64.2211], [65.7474, 67.2736, 68.7999]], + [[70.3261, 71.8524, 73.3786], [74.9049, 76.4312, 77.9574], [79.4837, 81.0099, 82.5362]], + ], + [ + [[29.3580, 30.0760, 30.7941], [31.5121, 32.2301, 32.9481], [33.6661, 34.3842, 35.1022]], + [[35.8202, 36.5382, 37.2563], [37.9743, 38.6923, 39.4103], [40.1283, 40.8464, 41.5644]], + [[42.2824, 43.0004, 43.7184], [44.4365, 45.1545, 45.8725], [46.5905, 47.3085, 48.0266]], + ], + [ + [[81.0000, 82.0000, 83.0000], [84.0000, 85.0000, 86.0000], [87.0000, 88.0000, 89.0000]], + [[90.0000, 91.0000, 92.0000], [93.0000, 94.0000, 95.0000], [96.0000, 97.0000, 98.0000]], + [ + [99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000], + ], + ], + ] ) ), ] ) TESTS.append( [ - {"device": device, "rotate_range": (1.0, 1.0, 1.0), "shear_range": (0.1,), "scale_range": (1.2,), - "foreground_oversampling_prob": 0.9}, - {"grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3))), "image_size": [5, 5, 5], "fg_indices": - [[1, 1, 1, 1], [1, 2, 2, 2]]}, + { + "device": device, + "rotate_range": (1.0, 1.0, 1.0), + "shear_range": (0.1,), + "scale_range": (1.2,), + "foreground_oversampling_prob": 0.9, + }, + { + "grid": p(torch.arange(0, 108).reshape((4, 3, 3, 3))), + "image_size": [5, 5, 5], + "fg_indices": [[1, 1, 1, 1], [1, 2, 2, 2]], + }, p( - np.array([[[[-50.6643, -50.7190, -50.7737], - [-50.8284, -50.8830, -50.9377], - [-50.9924, -51.0471, -51.1017]], - - [[-51.1564, -51.2111, -51.2657], - [-51.3204, -51.3751, -51.4298], - [-51.4844, -51.5391, -51.5938]], - - [[-51.6485, -51.7031, -51.7578], - [-51.8125, -51.8671, -51.9218], - [-51.9765, -52.0312, -52.0858]]], - - [[[-90.4777, -90.5975, -90.7173], - [-90.8371, -90.9569, -91.0767], - [-91.1965, -91.3164, -91.4362]], - - [[-91.5560, -91.6758, -91.7956], - [-91.9154, -92.0352, -92.1550], - [-92.2748, -92.3947, -92.5145]], - - [[-92.6343, -92.7541, -92.8739], - [-92.9937, -93.1135, -93.2334], - [-93.3532, -93.4730, -93.5928]]], - - [[[-34.4885, -34.5587, -34.6289], - [-34.6991, -34.7693, -34.8395], - [-34.9097, -34.9799, -35.0501]], - - [[-35.1203, -35.1906, -35.2608], - [-35.3310, -35.4012, -35.4714], - [-35.5416, -35.6118, -35.6820]], - - [[-35.7522, -35.8224, -35.8926], - [-35.9628, -36.0330, -36.1032], - [-36.1735, -36.2437, -36.3139]]], - - [[[81.0000, 82.0000, 83.0000], - [84.0000, 85.0000, 86.0000], - [87.0000, 88.0000, 89.0000]], - - [[90.0000, 91.0000, 92.0000], - [93.0000, 94.0000, 95.0000], - [96.0000, 97.0000, 98.0000]], - - [[99.0000, 100.0000, 101.0000], - [102.0000, 103.0000, 104.0000], - [105.0000, 106.0000, 107.0000]]]]) + np.array( + [ + [ + [ + [-50.6643, -50.7190, -50.7737], + [-50.8284, -50.8830, -50.9377], + [-50.9924, -51.0471, -51.1017], + ], + [ + [-51.1564, -51.2111, -51.2657], + [-51.3204, -51.3751, -51.4298], + [-51.4844, -51.5391, -51.5938], + ], + [ + [-51.6485, -51.7031, -51.7578], + [-51.8125, -51.8671, -51.9218], + [-51.9765, -52.0312, -52.0858], + ], + ], + [ + [ + [-90.4777, -90.5975, -90.7173], + [-90.8371, -90.9569, -91.0767], + [-91.1965, -91.3164, -91.4362], + ], + [ + [-91.5560, -91.6758, -91.7956], + [-91.9154, -92.0352, -92.1550], + [-92.2748, -92.3947, -92.5145], + ], + [ + [-92.6343, -92.7541, -92.8739], + [-92.9937, -93.1135, -93.2334], + [-93.3532, -93.4730, -93.5928], + ], + ], + [ + [ + [-34.4885, -34.5587, -34.6289], + [-34.6991, -34.7693, -34.8395], + [-34.9097, -34.9799, -35.0501], + ], + [ + [-35.1203, -35.1906, -35.2608], + [-35.3310, -35.4012, -35.4714], + [-35.5416, -35.6118, -35.6820], + ], + [ + [-35.7522, -35.8224, -35.8926], + [-35.9628, -36.0330, -36.1032], + [-36.1735, -36.2437, -36.3139], + ], + ], + [ + [[81.0000, 82.0000, 83.0000], [84.0000, 85.0000, 86.0000], [87.0000, 88.0000, 89.0000]], + [[90.0000, 91.0000, 92.0000], [93.0000, 94.0000, 95.0000], [96.0000, 97.0000, 98.0000]], + [ + [99.0000, 100.0000, 101.0000], + [102.0000, 103.0000, 104.0000], + [105.0000, 106.0000, 107.0000], + ], + ], + ] + ) ), ] ) diff --git a/tests/test_rand_affined.py b/tests/test_rand_affined.py index fe8b610c0b..e8b4504d18 100644 --- a/tests/test_rand_affined.py +++ b/tests/test_rand_affined.py @@ -93,7 +93,9 @@ "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), "seg": MetaTensor(torch.arange(64).reshape((1, 8, 8))), }, - torch.tensor([[[32.10919, 22.357067, 12.604945], [38.193462, 28.44134, 18.689217], [44.277733, 34.52561, 24.773487]]]), + torch.tensor( + [[[32.10919, 22.357067, 12.604945], [38.193462, 28.44134, 18.689217], [44.277733, 34.52561, 24.773487]]] + ), ] ) TESTS.append( @@ -116,14 +118,16 @@ { "img": MetaTensor( torch.tensor( - [[[32.10919, 22.357067, 12.604945], - [38.193462, 28.44134, 18.689217], - [44.277733, 34.52561, 24.773487]]] + [ + [ + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], + ] + ] ) ), - "seg": MetaTensor(torch.tensor([[[35., 19., 12.], - [36., 28., 20.], - [45., 37., 21.]]])), + "seg": MetaTensor(torch.tensor([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), }, ] ) @@ -141,10 +145,7 @@ mode=GridSampleMode.BILINEAR, ), {"img": MetaTensor(torch.ones((1, 3, 3, 3))), "seg": MetaTensor(torch.ones((1, 3, 3, 3)))}, - torch.tensor([[[[1.0000, 0.7835], - [1.0000, 1.0000]], - [[0.9465, 1.0000], - [0.4705, 1.0000]]]]), + torch.tensor([[[[1.0000, 0.7835], [1.0000, 1.0000]], [[0.9465, 1.0000], [0.4705, 1.0000]]]]), ] ) TESTS.append( @@ -167,14 +168,16 @@ { "img": MetaTensor( np.array( - [[[32.10919, 22.357067, 12.604945], - [38.193462, 28.44134, 18.689217], - [44.277733, 34.52561, 24.773487]]] + [ + [ + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], + ] + ] ) ), - "seg": MetaTensor(np.array([[[35., 19., 12.], - [36., 28., 20.], - [45., 37., 21.]]])), + "seg": MetaTensor(np.array([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), }, ] ) @@ -199,20 +202,22 @@ { "img": MetaTensor( torch.tensor( - [[[32.10919, 22.357067, 12.604945], - [38.193462, 28.44134, 18.689217], - [44.277733, 34.52561, 24.773487]]] + [ + [ + [32.10919, 22.357067, 12.604945], + [38.193462, 28.44134, 18.689217], + [44.277733, 34.52561, 24.773487], + ] + ] ) ), - "seg": MetaTensor(torch.tensor([[[35., 19., 12.], - [36., 28., 20.], - [45., 37., 21.]]])), + "seg": MetaTensor(torch.tensor([[[35.0, 19.0, 12.0], [36.0, 28.0, 20.0], [45.0, 37.0, 21.0]]])), }, ] ) seg = MetaTensor(torch.arange(64).reshape((1, 8, 8))) - seg.meta['foreground_sample_locations'] = [[1, 2, 3], [2, 3, 4]] + seg.meta["foreground_sample_locations"] = [[1, 2, 3], [2, 3, 4]] TESTS.append( [ dict( @@ -229,21 +234,20 @@ keys=("img", "seg"), device=device, ), - { - "img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), - "seg": seg, - }, + {"img": MetaTensor(torch.arange(64).reshape((1, 8, 8))), "seg": seg}, { "img": MetaTensor( torch.tensor( - [[[25.449093, 24.070652, 21.291996], - [28.34988, 27.144796, 25.939713], - [31.250668, 30.045584, 28.8405]]] + [ + [ + [25.449093, 24.070652, 21.291996], + [28.34988, 27.144796, 25.939713], + [31.250668, 30.045584, 28.8405], + ] + ] ) ), - "seg": MetaTensor(torch.tensor([[[22., 23., 23.], - [28., 30., 23.], - [35., 28., 29.]]])), + "seg": MetaTensor(torch.tensor([[[22.0, 23.0, 23.0], [28.0, 30.0, 23.0], [35.0, 28.0, 29.0]]])), }, ] ) diff --git a/tests/test_rand_simulate_low_resolution.py b/tests/test_rand_simulate_low_resolution.py index 1161ed3d70..7d05faad36 100644 --- a/tests/test_rand_simulate_low_resolution.py +++ b/tests/test_rand_simulate_low_resolution.py @@ -23,51 +23,49 @@ for p in TEST_NDARRAYS: TESTS.append( [ - dict( - prob=1.0, - zoom_range=(0.8, 0.81), + dict(prob=1.0, zoom_range=(0.8, 0.81)), + p( + np.array( + [ + [ + [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]], + [[16, 17, 18, 19], [20, 21, 22, 23], [24, 25, 26, 27], [28, 29, 30, 31]], + [[32, 33, 34, 35], [36, 37, 38, 39], [40, 41, 42, 43], [44, 45, 46, 47]], + [[48, 49, 50, 51], [52, 53, 54, 55], [56, 57, 58, 59], [60, 61, 62, 63]], + ] + ] + ) + ), + np.array( + [ + [ + [ + [0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000], + ], + [ + [10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000], + ], + [ + [22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000], + ], + [ + [32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000], + ], + ] + ] ), - p(np.array( - [[[[0, 1, 2, 3], - [4, 5, 6, 7], - [8, 9, 10, 11], - [12, 13, 14, 15]], - - [[16, 17, 18, 19], - [20, 21, 22, 23], - [24, 25, 26, 27], - [28, 29, 30, 31]], - - [[32, 33, 34, 35], - [36, 37, 38, 39], - [40, 41, 42, 43], - [44, 45, 46, 47]], - - [[48, 49, 50, 51], - [52, 53, 54, 55], - [56, 57, 58, 59], - [60, 61, 62, 63]]]] - )), - - np.array([[[[0.0000, 0.6250, 1.3750, 2.0000], - [2.5000, 3.1250, 3.8750, 4.5000], - [5.5000, 6.1250, 6.8750, 7.5000], - [8.0000, 8.6250, 9.3750, 10.0000]], - - [[10.0000, 10.6250, 11.3750, 12.0000], - [12.5000, 13.1250, 13.8750, 14.5000], - [15.5000, 16.1250, 16.8750, 17.5000], - [18.0000, 18.6250, 19.3750, 20.0000]], - - [[22.0000, 22.6250, 23.3750, 24.0000], - [24.5000, 25.1250, 25.8750, 26.5000], - [27.5000, 28.1250, 28.8750, 29.5000], - [30.0000, 30.6250, 31.3750, 32.0000]], - - [[32.0000, 32.6250, 33.3750, 34.0000], - [34.5000, 35.1250, 35.8750, 36.5000], - [37.5000, 38.1250, 38.8750, 39.5000], - [40.0000, 40.6250, 41.3750, 42.0000]]]]) ] ) diff --git a/tests/test_rand_simulate_low_resolutiond.py b/tests/test_rand_simulate_low_resolutiond.py index 0925922da6..f058ec3b2b 100644 --- a/tests/test_rand_simulate_low_resolutiond.py +++ b/tests/test_rand_simulate_low_resolutiond.py @@ -23,30 +23,38 @@ for p in TEST_NDARRAYS: TESTS.append( [ - dict(keys=["img", "seg"], - prob=1.0, - zoom_range=(0.8, 0.81)), - {"img": p(np.arange(64).reshape(1, 4, 4, 4)), - "seg": p(np.arange(64).reshape(1, 4, 4, 4))}, - np.array([[[[0.0000, 0.6250, 1.3750, 2.0000], - [2.5000, 3.1250, 3.8750, 4.5000], - [5.5000, 6.1250, 6.8750, 7.5000], - [8.0000, 8.6250, 9.3750, 10.0000]], - - [[10.0000, 10.6250, 11.3750, 12.0000], - [12.5000, 13.1250, 13.8750, 14.5000], - [15.5000, 16.1250, 16.8750, 17.5000], - [18.0000, 18.6250, 19.3750, 20.0000]], - - [[22.0000, 22.6250, 23.3750, 24.0000], - [24.5000, 25.1250, 25.8750, 26.5000], - [27.5000, 28.1250, 28.8750, 29.5000], - [30.0000, 30.6250, 31.3750, 32.0000]], - - [[32.0000, 32.6250, 33.3750, 34.0000], - [34.5000, 35.1250, 35.8750, 36.5000], - [37.5000, 38.1250, 38.8750, 39.5000], - [40.0000, 40.6250, 41.3750, 42.0000]]]]), + dict(keys=["img", "seg"], prob=1.0, zoom_range=(0.8, 0.81)), + {"img": p(np.arange(64).reshape(1, 4, 4, 4)), "seg": p(np.arange(64).reshape(1, 4, 4, 4))}, + np.array( + [ + [ + [ + [0.0000, 0.6250, 1.3750, 2.0000], + [2.5000, 3.1250, 3.8750, 4.5000], + [5.5000, 6.1250, 6.8750, 7.5000], + [8.0000, 8.6250, 9.3750, 10.0000], + ], + [ + [10.0000, 10.6250, 11.3750, 12.0000], + [12.5000, 13.1250, 13.8750, 14.5000], + [15.5000, 16.1250, 16.8750, 17.5000], + [18.0000, 18.6250, 19.3750, 20.0000], + ], + [ + [22.0000, 22.6250, 23.3750, 24.0000], + [24.5000, 25.1250, 25.8750, 26.5000], + [27.5000, 28.1250, 28.8750, 29.5000], + [30.0000, 30.6250, 31.3750, 32.0000], + ], + [ + [32.0000, 32.6250, 33.3750, 34.0000], + [34.5000, 35.1250, 35.8750, 36.5000], + [37.5000, 38.1250, 38.8750, 39.5000], + [40.0000, 40.6250, 41.3750, 42.0000], + ], + ] + ] + ), ] ) diff --git a/tests/test_sample_foreground_locations.py b/tests/test_sample_foreground_locations.py index 8818074c80..536ea373d9 100644 --- a/tests/test_sample_foreground_locations.py +++ b/tests/test_sample_foreground_locations.py @@ -22,14 +22,7 @@ TEST_CASES = [] for p in TEST_NDARRAYS: for num_samples in [20, 30]: - TEST_CASES.append( - [ - {"num_samples": num_samples}, - p(np.ones([10, 10, 9])), - p(np.ones([10, 10, 9])), - num_samples, - ] - ) + TEST_CASES.append([{"num_samples": num_samples}, p(np.ones([10, 10, 9])), p(np.ones([10, 10, 9])), num_samples]) class TestSampleForegroundLocations(unittest.TestCase): @@ -43,5 +36,6 @@ def test_value_shape(self, input_param, img, out, expected_num_samples): # output tensor should remain unchanged assert_allclose(result, out, rtol=1e-3, type_test="tensor") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sample_foreground_locationsd.py b/tests/test_sample_foreground_locationsd.py index 2dcc7ceed0..3eb4b40474 100644 --- a/tests/test_sample_foreground_locationsd.py +++ b/tests/test_sample_foreground_locationsd.py @@ -25,14 +25,8 @@ TEST_CASES.append( [ {"label_keys": ["label"], "num_samples": num_samples}, - { - "image": p(np.zeros([10, 10, 9])), - "label": p(np.ones([10, 10, 9])) - }, - { - "image": p(np.zeros([10, 10, 9])), - "label": p(np.ones([10, 10, 9])) - }, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.ones([10, 10, 9]))}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.ones([10, 10, 9]))}, num_samples, ] ) @@ -42,14 +36,18 @@ class TestSampleForegroundLocationsd(unittest.TestCase): @parameterized.expand(TEST_CASES) def test_value_shape(self, input_param, test_input, output, expected_num_samples): result = SampleForegroundLocationsd(**input_param)(test_input) - assert_allclose(result["image"], output["image"], rtol=1e-3) # "image" should not have been affected by transform - assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") # "label" should not have been affected by transform + assert_allclose( + result["image"], output["image"], rtol=1e-3 + ) # "image" should not have been affected by transform + assert_allclose( + result["label"], output["label"], rtol=1e-3, type_test="tensor" + ) # "label" should not have been affected by transform if "label" in result: self.assertEqual(len(result["label"].meta["foreground_sample_locations"]), expected_num_samples) # output tensor should remain unchanged - #assert_allclose(result["image"], output["image"], rtol=1e-3, type_test="tensor") - #assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") + # assert_allclose(result["image"], output["image"], rtol=1e-3, type_test="tensor") + # assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_scale_intensity_fixed_mean.py b/tests/test_scale_intensity_fixed_mean.py index 54e0adba62..afbcd46141 100644 --- a/tests/test_scale_intensity_fixed_mean.py +++ b/tests/test_scale_intensity_fixed_mean.py @@ -17,7 +17,6 @@ from parameterized import parameterized from monai.transforms import ScaleIntensityFixedMean -from monai.transforms.utils import rescale_array from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose @@ -33,11 +32,13 @@ def test_factor_scale(self): def test_preserve_range(self, p): for channel_wise in [False, True]: factor = 0.9 - scaler = ScaleIntensityFixedMean(factor=factor, preserve_range=True, channel_wise=channel_wise, fixed_mean=False) + scaler = ScaleIntensityFixedMean( + factor=factor, preserve_range=True, channel_wise=channel_wise, fixed_mean=False + ) im = p(self.imt) result = scaler(im) - if False: # channel_wise: + if False: # channel_wise: out = [] for d in im: clip_min = d.min() @@ -65,7 +66,7 @@ def test_fixed_mean(self, p): result = scaler(im) mn = im.mean() im = im - mn - expected = (1+factor)*im + expected = (1 + factor) * im expected = expected + mn assert_allclose(result, expected, type_test="tensor", atol=1e-7) @@ -73,14 +74,16 @@ def test_fixed_mean(self, p): def test_fixed_mean_preserve_range(self, p): for channel_wise in [False, True]: factor = 0.9 - scaler = ScaleIntensityFixedMean(factor=factor, preserve_range=True, fixed_mean=True, channel_wise=channel_wise) + scaler = ScaleIntensityFixedMean( + factor=factor, preserve_range=True, fixed_mean=True, channel_wise=channel_wise + ) im = p(self.imt) clip_min = im.min() clip_max = im.max() result = scaler(im) mn = im.mean() im = im - mn - expected = (1+factor)*im + expected = (1 + factor) * im expected = expected + mn expected[expected < clip_min] = clip_min expected[expected > clip_max] = clip_max From 98729ed23cd5d660bd3f20228b556eab98939859 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Tue, 9 May 2023 13:57:18 +0100 Subject: [PATCH 17/24] Fixed docstring --- monai/transforms/spatial/array.py | 95 ++++++++++++++++--------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 177228b839..333dd363fe 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1623,53 +1623,54 @@ def __init__( dtype: DtypeLike = np.float32, ) -> None: """ - rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then - `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter - for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. - This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be - in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` - for dim0 and nothing for the remaining dimensions. - prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. - shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select - shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, - take a 3D affine as example:: - - [ - [1.0, params[0], params[1], 0.0], - [params[2], 1.0, params[3], 0.0], - [params[4], params[5], 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ] - prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. - translate_range: translate range with format matching `rotate_range`, it defines the range to randomly - select voxels to translate for every spatial dims. - prob_translate: probability to perform a random translation. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. - scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select - the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. - This allows 0 to correspond to no change (i.e., a scaling of 1.0). - prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating - `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. - foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground - location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of - a translation to a randomly selected sample of the list of foreground locations and a further random - translation based on prob_translate and translate_range. Final translation parameters are clipped to a - valid range which is defined such that for each spatial dimension the center of the grid cannot be - closer than half the grid size to the corner of the input image. In the case in which a translation to - a foreground location is not required, the translation parameters will be sampled uniformly within the - valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range - clipping is applied. - device: device to store the output grid data. - dtype: data type for the grid computation. Defaults to ``np.float32``. - If ``None``, use the data type of input data (if `grid` is provided). - - See also: - - :py:meth:`monai.transforms.utils.create_rotate` - - :py:meth:`monai.transforms.utils.create_shear` - - :py:meth:`monai.transforms.utils.create_translate` - - :py:meth:`monai.transforms.utils.create_scale` + Args: + rotate_range: angle range in radians. If element `i` is a pair of (min, max) values, then + `uniform[-rotate_range[i][0], rotate_range[i][1])` will be used to generate the rotation parameter + for the `i`th spatial dimension. If not, `uniform[-rotate_range[i], rotate_range[i])` will be used. + This can be altered on a per-dimension basis. E.g., `((0,3), 1, ...)`: for dim0, rotation will be + in range `[0, 3]`, and for dim1 `[-1, 1]` will be used. Setting a single value will use `[-x, x]` + for dim0 and nothing for the remaining dimensions. + prob_rotate: probability to perform a random rotation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random rotation is given by `prob`*`prob_rotate`. + shear_range: shear range with format matching `rotate_range`, it defines the range to randomly select + shearing factors(a tuple of 2 floats for 2D, a tuple of 6 floats for 3D) for affine matrix, + take a 3D affine as example:: + + [ + [1.0, params[0], params[1], 0.0], + [params[2], 1.0, params[3], 0.0], + [params[4], params[5], 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + prob_shear: probability to perform a random shearing. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random shearing is given by `prob`*`prob_shear`. + translate_range: translate range with format matching `rotate_range`, it defines the range to randomly + select voxels to translate for every spatial dims. + prob_translate: probability to perform a random translation. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random translation is given by `prob`*`prob_translate`. + scale_range: scaling range with format matching `rotate_range`. it defines the range to randomly select + the scale factor to translate for every spatial dims. A value of 1.0 is added to the result. + This allows 0 to correspond to no change (i.e., a scaling of 1.0). + prob_scale: probability to perform a random scaling. This probability is evaluated after evaluating + `prob`, i.e., the total probability to perform a random scaling is given by `prob`*`prob_scale`. + foreground_oversampling_prob: probability to translate the center of the sampling grid to a foreground + location. When `foreground_oversampling_prob` is used, a translation to a foreground location consist of + a translation to a randomly selected sample of the list of foreground locations and a further random + translation based on prob_translate and translate_range. Final translation parameters are clipped to a + valid range which is defined such that for each spatial dimension the center of the grid cannot be + closer than half the grid size to the corner of the input image. In the case in which a translation to + a foreground location is not required, the translation parameters will be sampled uniformly within the + valid range. If `foreground_oversampling_prob` is `None`, the default behaviour without valid range + clipping is applied. + device: device to store the output grid data. + dtype: data type for the grid computation. Defaults to ``np.float32``. + If ``None``, use the data type of input data (if `grid` is provided). + + See also: + - :py:meth:`monai.transforms.utils.create_rotate` + - :py:meth:`monai.transforms.utils.create_shear` + - :py:meth:`monai.transforms.utils.create_translate` + - :py:meth:`monai.transforms.utils.create_scale` """ self.rotate_range = ensure_tuple(rotate_range) From 8694b1e8b4233e7b61f72a4c7f53901190ca84da Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Thu, 11 May 2023 09:07:48 +0100 Subject: [PATCH 18/24] Bug fix in fg_indices check --- monai/transforms/post/array.py | 4 - monai/transforms/spatial/array.py | 2 +- monai/transforms/utility/array.py | 8 +- tests/test_append_downsampled.py | 2 +- tests/test_nnunet_transforms.py | 175 +++++++++++++++++++++ tests/test_sample_foreground_locations.py | 5 + tests/test_sample_foreground_locationsd.py | 16 +- 7 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 tests/test_nnunet_transforms.py diff --git a/monai/transforms/post/array.py b/monai/transforms/post/array.py index 70fdfeabd5..7bca908754 100644 --- a/monai/transforms/post/array.py +++ b/monai/transforms/post/array.py @@ -163,10 +163,6 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: ret = [] for s in self.downsampled_shapes: downsampled_img = interpolate(input=img, size=s, mode=self.mode) - - downsampled_img = CastToType(dtype=np.uint8)( - downsampled_img - ) # TODO: restricts functions to work with uint8, check influence of removing this line downsampled_img = EnsureType()(downsampled_img) ret.append(downsampled_img) diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py index 333dd363fe..c7d9cac3ec 100644 --- a/monai/transforms/spatial/array.py +++ b/monai/transforms/spatial/array.py @@ -1770,7 +1770,7 @@ def __call__( max_transl = np.array([max(tr, 0) for tr in max_transl]) if ( - self.translate_to_foreground + self.translate_to_foreground and (fg_indices is not None) and len(fg_indices) > 0 ): # first translate to foreground pixel, then add the random translation, then clip to valid range # randomly pick one of the previously sampled foreground pixels to translate the center point of the grid to # select one fg sample based on float randomized in randomize function diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 9d0187cc38..6f72072bb2 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -1014,9 +1014,11 @@ def __call__(self, label: NdarrayOrTensor) -> NdarrayOrTensor: all_locations = (label > 0).nonzero() # TODO: Handle case of no foreground - random_indices = torch.randint(0, len(all_locations), (self.num_samples,)) - random_samples = all_locations[random_indices] - + if len(all_locations) > 0: + random_indices = torch.randint(0, len(all_locations), (self.num_samples,)) + random_samples = all_locations[random_indices] + else: + random_samples = torch.tensor([]) label.meta["foreground_sample_locations"] = random_samples.cpu().numpy() return label diff --git a/tests/test_append_downsampled.py b/tests/test_append_downsampled.py index 09c7652776..40b2ef5191 100644 --- a/tests/test_append_downsampled.py +++ b/tests/test_append_downsampled.py @@ -21,7 +21,7 @@ TEST_CASES = [] for p in TEST_NDARRAYS: - for val in [2, 3]: # TODO: include negative and float, currently doesn't work because of uint8 conversion + for val in [2, 3, -1, -2.5]: downsampled_shapes = [(5, 5, 5), (4, 4, 4)] TEST_CASES.append( [ diff --git a/tests/test_nnunet_transforms.py b/tests/test_nnunet_transforms.py new file mode 100644 index 0000000000..10e159fb8b --- /dev/null +++ b/tests/test_nnunet_transforms.py @@ -0,0 +1,175 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import itertools +import os +import tempfile +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.data import MetaTensor, set_track_meta +from monai.transforms import RandAffined, LoadImaged, EnsureChannelFirstd, CropForegroundd, NormalizeIntensityd, \ + SampleForegroundLocationsd, RandGaussianNoised, RandGaussianSmoothd, RandScaleIntensityd, \ + RandScaleIntensityFixedMeand, RandSimulateLowResolutiond, RandAdjustContrastd, RandFlipd, Compose, \ + AppendDownsampledd, CastToTyped, EnsureTyped, ConcatItemsd, DeleteItemsd +from tests.utils import assert_allclose, is_tf32_env +import nibabel as nib + +_rtol = 1e-3 if is_tf32_env() else 1e-4 + +TESTS = [] + +def get_deep_supr_label_shapes(deep_supr_num, patch_size, strides): + supr_label_shapes = [patch_size] + for i in range(deep_supr_num): + last_shape = supr_label_shapes[-1] + curr_strides = strides[ + i + 1] # ignore first set of strides, since they apply to downsampling prior to the first level + downsampled_shape = [int(np.round(last / curr)) for last, curr in zip(last_shape, curr_strides)] + supr_label_shapes.append(downsampled_shape) + return supr_label_shapes + + +modality_keys = ["image_0000", "image_0001"] + +# transform parameters +patch_size= [32, 64, 32] +strides = [[1, 1, 1], [2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2], [1, 2, 1]] +deep_supr_num = 3 +pos_sample_num = 2 +neg_sample_num = 1 +use_nonzero = True +use_prior = False + +label_keys = ["label"] +all_keys = modality_keys + label_keys + +# exclude the prior from the intensity transforms +mod_inty_keys = modality_keys[:-1] if use_prior else modality_keys + +load_image = LoadImaged(keys=all_keys, image_only=True) +ensure_channel_first = EnsureChannelFirstd(keys=all_keys) +crop_transform = CropForegroundd(keys=all_keys, source_key=mod_inty_keys[0], start_coord_key=None, end_coord_key=None) + +norm_transform = NormalizeIntensityd(keys=mod_inty_keys, nonzero=use_nonzero) +sample_foreground_locations = SampleForegroundLocationsd(label_keys=label_keys, num_samples=10000) + +rand_affine = RandAffined( + keys=all_keys, + mode=(3,)*len(modality_keys) + ("nearest", ), # 3 means third order spline interpolation + prob=1.0, + spatial_size=patch_size, + rotate_range= (30 / 360 * 2 * np.pi, 30 / 360 * 2 * np.pi, 30 / 360 * 2 * np.pi), + prob_rotate=0.2, + translate_range=(0, 0, 0), + foreground_oversampling_prob=pos_sample_num / neg_sample_num, + label_key_for_foreground_oversampling="label", + prob_translate=1.0, + scale_range=((-0.3, 0.4), (-0.3, 0.4), (-0.3, 0.4)), + prob_scale=0.2, + padding_mode=("constant",)*len(modality_keys) + ("border", ), +) + +rand_gauss_noise = RandGaussianNoised(keys=mod_inty_keys, std=0.1, prob=0.1) + +rand_gauss_smooth = RandGaussianSmoothd(keys=mod_inty_keys, + sigma_x=(0.5, 1.0), + sigma_y=(0.5, 1.0), + sigma_z=(0.5, 1.0), + prob=0.2 * 0.5, ) # 0.5 comes from the per_channel_probability + +scale_intensity = RandScaleIntensityd(keys=mod_inty_keys, factors=[-0.25, 0.25], prob=0.15) + +shift_intensity = RandScaleIntensityFixedMeand(keys=mod_inty_keys, factors=[-0.25, 0.25], preserve_range=True, + prob=0.15) + +sim_lowres = RandSimulateLowResolutiond(keys=mod_inty_keys, prob=0.25*0.5, zoom_range=(0.5, 1.0)) + +adjust_contrast_inverted = RandAdjustContrastd(keys=mod_inty_keys, prob=0.1 * 1.0, gamma=(0.7, 1.5), + invert_image=True, retain_stats=True) + +adjust_contrast = RandAdjustContrastd(keys=mod_inty_keys, prob=0.3 * 1.0, gamma=(0.7, 1.5), invert_image=False, + retain_stats=True) + +mirror_x = RandFlipd(all_keys, spatial_axis=[0], prob=0.5) +mirror_y = RandFlipd(all_keys, spatial_axis=[1], prob=0.5) +mirror_z = RandFlipd(all_keys, spatial_axis=[2], prob=0.5) + +supr_label_shapes = get_deep_supr_label_shapes(deep_supr_num, patch_size, strides) + +transform = Compose([ + load_image, + ensure_channel_first, + crop_transform, + norm_transform, # -1 + sample_foreground_locations, # 0 + rand_affine, # 1 + rand_gauss_noise, # 2 + rand_gauss_smooth, # 3 + scale_intensity, # 4 + shift_intensity, # 5 + sim_lowres, # 6 + adjust_contrast_inverted, # 7 + adjust_contrast, # 8 + mirror_x, mirror_y, mirror_z, # 9 + AppendDownsampledd(label_keys, downsampled_shapes=supr_label_shapes), + CastToTyped(keys=modality_keys, dtype=np.float32), + EnsureTyped(keys=modality_keys), + ConcatItemsd(keys=modality_keys, name="image", dim=0), + DeleteItemsd(keys=modality_keys), +], unpack_items=True) + +TESTS.append( + [ + dict( + transform=transform, + keys=["image_0000", "image_0001", "label"], + fnames=["test_image_0000.nii.gz", + "test_image_0001.nii.gz", + "test_label.nii.gz"], + data=[np.arange(64 * 64 * 64).reshape((64, 64, 64)).astype(float), + np.arange(64 * 64 * 64).reshape((64, 64, 64)).astype(float), + (np.random.rand(64, 64, 64) > 0.25).astype(float)] + ) + ] +) + + +class TestNnunetTransforms(unittest.TestCase): + @parameterized.expand(x + [y] for x, y in itertools.product(TESTS, (True,))) + def test_nnunet_transforms(self, input_params, track_meta): + set_track_meta(track_meta) + + with tempfile.TemporaryDirectory() as tempdir: + data_dict = {} + for i in range(len(input_params['fnames'])): + name = input_params['fnames'][i] + key = input_params['keys'][i] + data = input_params['data'][i] + filename = os.path.join(tempdir, name) + nib.save(nib.Nifti1Image(data, np.eye(4)), filename) + data_dict.update({key: filename}) + + transform = input_params['transform'] + out = transform(data_dict) + + self.assertTrue("label" in out.keys()) + self.assertTrue("image" in out.keys()) # concatenation of image_000X .... + self.assertEqual(list(out['image'].shape), [len(modality_keys)]+patch_size) # concatenation has the right shape? + self.assertEqual(len(out['label']), deep_supr_num + 1) # list of tensor outputs (top level output plus deep supervision heads + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sample_foreground_locations.py b/tests/test_sample_foreground_locations.py index 536ea373d9..2725e0db84 100644 --- a/tests/test_sample_foreground_locations.py +++ b/tests/test_sample_foreground_locations.py @@ -24,6 +24,11 @@ for num_samples in [20, 30]: TEST_CASES.append([{"num_samples": num_samples}, p(np.ones([10, 10, 9])), p(np.ones([10, 10, 9])), num_samples]) +# test the case when input patch has no foreground +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append([{"num_samples": num_samples}, p(np.zeros([10, 10, 9])), p(np.zeros([10, 10, 9])), 0]) + class TestSampleForegroundLocations(unittest.TestCase): @parameterized.expand(TEST_CASES) diff --git a/tests/test_sample_foreground_locationsd.py b/tests/test_sample_foreground_locationsd.py index 3eb4b40474..10f1a29381 100644 --- a/tests/test_sample_foreground_locationsd.py +++ b/tests/test_sample_foreground_locationsd.py @@ -31,6 +31,18 @@ ] ) +# test the case when input patch has no foreground +for p in TEST_NDARRAYS: + for num_samples in [20, 30]: + TEST_CASES.append( + [ + {"label_keys": ["label"], "num_samples": num_samples}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.zeros([10, 10, 9]))}, + {"image": p(np.zeros([10, 10, 9])), "label": p(np.zeros([10, 10, 9]))}, + 0, + ] + ) + class TestSampleForegroundLocationsd(unittest.TestCase): @parameterized.expand(TEST_CASES) @@ -45,10 +57,6 @@ def test_value_shape(self, input_param, test_input, output, expected_num_samples if "label" in result: self.assertEqual(len(result["label"].meta["foreground_sample_locations"]), expected_num_samples) - # output tensor should remain unchanged - # assert_allclose(result["image"], output["image"], rtol=1e-3, type_test="tensor") - # assert_allclose(result["label"], output["label"], rtol=1e-3, type_test="tensor") - if __name__ == "__main__": unittest.main() From d1c39512c8beed25fa8f19c5b5f7c2b4ee62f704 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Thu, 11 May 2023 09:41:16 +0100 Subject: [PATCH 19/24] Code formatting --- monai/transforms/utility/array.py | 1 - 1 file changed, 1 deletion(-) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 6f72072bb2..325cd4f06b 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -1013,7 +1013,6 @@ def __call__(self, label: NdarrayOrTensor) -> NdarrayOrTensor: all_locations = (label > 0).nonzero() - # TODO: Handle case of no foreground if len(all_locations) > 0: random_indices = torch.randint(0, len(all_locations), (self.num_samples,)) random_samples = all_locations[random_indices] From e1d482e2706d68e658729c58ce51e323e15a8dda Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 19 May 2023 11:30:20 +0100 Subject: [PATCH 20/24] Updated docstring for PersistentStagedDataset --- monai/data/dataset.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/monai/data/dataset.py b/monai/data/dataset.py index b5153a754f..0b286c400b 100644 --- a/monai/data/dataset.py +++ b/monai/data/dataset.py @@ -418,6 +418,12 @@ def _transform(self, index: int): class PersistentStagedDataset(PersistentDataset): + """ + PersistentStagedDataset works like PersistentDataset, but additionally allows to cache the data after a first + sequence of non-random transforms and in a second step continue with a second sequence of non-random transforms. + This is useful if parameters of the second sequence of non-random transforms depend on the whole dataset after + the first transform sequence has been applied. + """ def __init__( self, new_transform: Sequence[Callable] | Callable, @@ -430,6 +436,47 @@ def __init__( hash_transform: Callable[..., bytes] | None = pickle_hash_transform_names, reset_ops_id: bool = True, ) -> None: + """ + Args: + data: input data file paths to load and transform to generate dataset for model. + `PersistentStagedDataset` expects input data to be a list of serializable + and hashes them as cache keys using `hash_func`. + new_transform: if `old_transform` is not used, PersistentStagedDataset will behave like PersistentDataset + where the role of `transform` is replaced by `new_transform`. If `old_transform` is used, + `PersistentStagedDataset` will first check in the `cache_dir` if the data transformed by `old_transform` + is available. If it is availble, it will load the cached data and apply the `new_transform`, otherwise + it will first apply the `old_transform` and then the `new_transform`. + old_transform: sequence of transforms expected to have been applied and the resulting data to have + been cached in `cache_dir`. + cache_dir: If specified, this is the location for persistent storage + of pre-computed transformed data tensors. The cache_dir is computed once, and + persists on disk until explicitly removed. Different runs, programs, experiments + may share a common cache dir provided that the transforms pre-processing is consistent. + If `cache_dir` doesn't exist, will automatically create it. + If `cache_dir` is `None`, there is effectively no caching. + hash_func: a callable to compute hash from data items to be cached. + defaults to `monai.data.utils.pickle_hashing`. + pickle_module: string representing the module used for pickling metadata and objects, + default to `"pickle"`. due to the pickle limitation in multi-processing of Dataloader, + we can't use `pickle` as arg directly, so here we use a string name instead. + if want to use other pickle module at runtime, just register like: + >>> from monai.data import utils + >>> utils.SUPPORTED_PICKLE_MOD["test"] = other_pickle + this arg is used by `torch.save`, for more details, please check: + https://pytorch.org/docs/stable/generated/torch.save.html#torch.save, + and ``monai.data.utils.SUPPORTED_PICKLE_MOD``. + pickle_protocol: can be specified to override the default protocol, default to `2`. + this arg is used by `torch.save`, for more details, please check: + https://pytorch.org/docs/stable/generated/torch.save.html#torch.save. + hash_transform: a callable to compute hash from the transform information when caching. + This may reduce errors due to transforms changing during experiments. Default to + `monai.data.utils.pickle_hash_transform_names`. + reset_ops_id: whether to set `TraceKeys.ID` to ``Tracekys.NONE``, defaults to ``True``. + When this is enabled, the traced transform instance IDs will be removed from the cached MetaTensors. + This is useful for skipping the transform instance checks when inverting applied operations + using the cached content and with re-created transform instances. + + """ self.old_transform = old_transform if not self.old_transform: # if no old_transform is passed, create a normal PersistentDataset based on the new_transform From 3a21fdf03ce86210a760b068bd2eb36ed0fd68dc Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Fri, 19 May 2023 15:40:33 +0100 Subject: [PATCH 21/24] Updated docsting --- monai/transforms/intensity/array.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index d7100e2675..dbb95dedd4 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -471,10 +471,8 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: class ScaleIntensityFixedMean(Transform): """ - Scale the intensity of input image to the given value range (minv, maxv). - If `minv` and `maxv` not provided, use `factor` to scale image by ``v = v * (1 + factor)``. - Subtract the mean intensity before scaling with `factor`, then add the same value after scaling - to ensure that the output has the same mean as the input. + Scale the intensity of input image ``v = v * (1 + factor)``, then shift the output so that the output image has the + same mean as the input. """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] From d0d60c6506a4025574864595f5ececd6153a3354 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Mon, 22 May 2023 11:29:36 +0100 Subject: [PATCH 22/24] Fixed scaling transform for channelwise application --- monai/transforms/intensity/array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index dbb95dedd4..631fd0b06b 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -528,7 +528,7 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: mn = d.mean() d = d - mn - out_channel = (d * (1 + self.factor)) if self.factor is not None else img_t + out_channel = (d * (1 + self.factor)) if self.factor is not None else d if self.fixed_mean: out_channel = out_channel + mn From dce66d08b76d09cdc9869c9f153b73f6d3aa0634 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Mon, 22 May 2023 13:26:07 +0100 Subject: [PATCH 23/24] Added (Rand)ScaleScaleIntensityFixedMean(d) and modified (Rand)AdjustContrast(d) with by adding arguments --- monai/transforms/__init__.py | 5 + monai/transforms/intensity/array.py | 204 +++++++++++++++++- monai/transforms/intensity/dictionary.py | 98 ++++++++- tests/test_adjust_contrast.py | 40 +++- tests/test_adjust_contrastd.py | 48 +++-- tests/test_rand_scale_intensity_fixed_mean.py | 41 ++++ .../test_rand_scale_intensity_fixed_meand.py | 41 ++++ tests/test_scale_intensity_fixed_mean.py | 94 ++++++++ 8 files changed, 537 insertions(+), 34 deletions(-) create mode 100644 tests/test_rand_scale_intensity_fixed_mean.py create mode 100644 tests/test_rand_scale_intensity_fixed_meand.py create mode 100644 tests/test_scale_intensity_fixed_mean.py diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 75cbec5607..936d81d002 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -118,10 +118,12 @@ RandKSpaceSpikeNoise, RandRicianNoise, RandScaleIntensity, + RandScaleIntensityFixedMean, RandShiftIntensity, RandStdShiftIntensity, SavitzkyGolaySmooth, ScaleIntensity, + ScaleIntensityFixedMean, ScaleIntensityRange, ScaleIntensityRangePercentiles, ShiftIntensity, @@ -198,6 +200,9 @@ RandScaleIntensityd, RandScaleIntensityD, RandScaleIntensityDict, + RandScaleIntensityFixedMeand, + RandScaleIntensityFixedMeanD, + RandScaleIntensityFixedMeanDict, RandShiftIntensityd, RandShiftIntensityD, RandShiftIntensityDict, diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py index 588913f579..631fd0b06b 100644 --- a/monai/transforms/intensity/array.py +++ b/monai/transforms/intensity/array.py @@ -49,6 +49,8 @@ "RandBiasField", "ScaleIntensity", "RandScaleIntensity", + "ScaleIntensityFixedMean", + "RandScaleIntensityFixedMean", "NormalizeIntensity", "ThresholdIntensity", "ScaleIntensityRange", @@ -467,6 +469,160 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: return ret +class ScaleIntensityFixedMean(Transform): + """ + Scale the intensity of input image ``v = v * (1 + factor)``, then shift the output so that the output image has the + same mean as the input. + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + factor: float | None = 0, + preserve_range: bool = False, + fixed_mean: bool = True, + channel_wise: bool = False, + dtype: DtypeLike = np.float32, + ) -> None: + """ + Args: + factor: factor scale by ``v = v * (1 + factor)``. + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + """ + self.factor = factor + self.preserve_range = preserve_range + self.fixed_mean = fixed_mean + self.channel_wise = channel_wise + self.dtype = dtype + + def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: + """ + Apply the transform to `img`. + + Raises: + ValueError: When ``self.fixed_mean=True`` and ``self.factor=None``. Incompatible values. + + """ + + if self.fixed_mean and not self.factor: + raise ValueError(f"{self.fixed_mean=} and {self.factor=} is incompatible.") + + img = convert_to_tensor(img, track_meta=get_track_meta()) + img_t = convert_to_tensor(img, track_meta=False) + ret: NdarrayOrTensor + if self.channel_wise: + out = [] + for d in img_t: + if self.preserve_range: + clip_min = d.min() + clip_max = d.max() + + if self.fixed_mean: + mn = d.mean() + d = d - mn + + out_channel = (d * (1 + self.factor)) if self.factor is not None else d + + if self.fixed_mean: + out_channel = out_channel + mn + + if self.preserve_range: + out_channel = clip(out_channel, clip_min, clip_max) + + out.append(out_channel) + ret = torch.stack(out) # type: ignore + else: + if self.preserve_range: + clip_min = img_t.min() + clip_max = img_t.max() + + if self.fixed_mean: + mn = img_t.mean() + img_t = img_t - mn + + ret = (img_t * (1 + self.factor)) if self.factor is not None else img_t + + if self.fixed_mean: + ret = ret + mn + + if self.preserve_range: + ret = clip(ret, clip_min, clip_max) + + ret = convert_to_dst_type(ret, dst=img, dtype=self.dtype or img_t.dtype)[0] + return ret + + +class RandScaleIntensityFixedMean(RandomizableTransform): + """ + Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor` + is randomly picked. Subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + """ + + backend = ScaleIntensityFixedMean.backend + + def __init__( + self, + prob: float = 0.1, + factors: Sequence[float] | float = 0, + fixed_mean: bool = True, + preserve_range: bool = False, + dtype: DtypeLike = np.float32, + ) -> None: + """ + Args: + factors: factor range to randomly scale by ``v = v * (1 + factor)``. + if single number, factor value is picked from (-factors, factors). + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + + """ + RandomizableTransform.__init__(self, prob) + if isinstance(factors, (int, float)): + self.factors = (min(-factors, factors), max(-factors, factors)) + elif len(factors) != 2: + raise ValueError("factors should be a number or pair of numbers.") + else: + self.factors = (min(factors), max(factors)) + self.factor = self.factors[0] + self.fixed_mean = fixed_mean + self.preserve_range = preserve_range + self.dtype = dtype + + def randomize(self, data: Any | None = None) -> None: + super().randomize(None) + if not self._do_transform: + return None + self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1]) + + def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTensor: + """ + Apply the transform to `img`. + """ + img = convert_to_tensor(img, track_meta=get_track_meta()) + if randomize: + self.randomize() + + if not self._do_transform: + return convert_data_type(img, dtype=self.dtype)[0] + + return ScaleIntensityFixedMean( + factor=self.factor, fixed_mean=self.fixed_mean, preserve_range=self.preserve_range, dtype=self.dtype + )(img) + + class RandScaleIntensity(RandomizableTransform): """ Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor` @@ -800,36 +956,61 @@ def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: class AdjustContrast(Transform): """ - Changes image intensity by gamma. Each pixel/voxel intensity is updated as:: + Changes image intensity with gamma transform. Each pixel/voxel intensity is updated as:: x = ((x - min) / intensity_range) ^ gamma * intensity_range + min Args: gamma: gamma value to adjust the contrast as function. + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input """ backend = [TransformBackends.TORCH, TransformBackends.NUMPY] - def __init__(self, gamma: float) -> None: + def __init__(self, gamma: float, invert_image: bool = False, retain_stats: bool = False) -> None: if not isinstance(gamma, (int, float)): raise ValueError(f"gamma must be a float or int number, got {type(gamma)} {gamma}.") self.gamma = gamma + self.invert_image = invert_image + self.retain_stats = retain_stats def __call__(self, img: NdarrayOrTensor) -> NdarrayOrTensor: """ Apply the transform to `img`. """ img = convert_to_tensor(img, track_meta=get_track_meta()) + + if self.invert_image: + img = -img + + if self.retain_stats: + mn = img.mean() + sd = img.std() + epsilon = 1e-7 img_min = img.min() img_range = img.max() - img_min ret: NdarrayOrTensor = ((img - img_min) / float(img_range + epsilon)) ** self.gamma * img_range + img_min + + if self.retain_stats: + # zero mean and normalize + ret = ret - ret.mean() + ret = ret / (ret.std() + 1e-8) + # restore old mean and standard deviation + ret = sd * ret + mn + + if self.invert_image: + ret = -ret + return ret class RandAdjustContrast(RandomizableTransform): """ - Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as:: + Randomly changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: x = ((x - min) / intensity_range) ^ gamma * intensity_range + min @@ -837,11 +1018,21 @@ class RandAdjustContrast(RandomizableTransform): prob: Probability of adjustment. gamma: Range of gamma values. If single number, value is picked from (0.5, gamma), default is (0.5, 4.5). + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input """ backend = AdjustContrast.backend - def __init__(self, prob: float = 0.1, gamma: Sequence[float] | float = (0.5, 4.5)) -> None: + def __init__( + self, + prob: float = 0.1, + gamma: Sequence[float] | float = (0.5, 4.5), + invert_image: bool = False, + retain_stats: bool = False, + ) -> None: RandomizableTransform.__init__(self, prob) if isinstance(gamma, (int, float)): @@ -856,6 +1047,8 @@ def __init__(self, prob: float = 0.1, gamma: Sequence[float] | float = (0.5, 4.5 self.gamma = (min(gamma), max(gamma)) self.gamma_value: float | None = None + self.invert_image: bool = invert_image + self.retain_stats: bool = retain_stats def randomize(self, data: Any | None = None) -> None: super().randomize(None) @@ -876,7 +1069,8 @@ def __call__(self, img: NdarrayOrTensor, randomize: bool = True) -> NdarrayOrTen if self.gamma_value is None: raise RuntimeError("gamma_value is not set, please call `randomize` function first.") - return AdjustContrast(self.gamma_value)(img) + + return AdjustContrast(self.gamma_value, invert_image=self.invert_image, retain_stats=self.retain_stats)(img) class ScaleIntensityRangePercentiles(Transform): diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 790cb38671..a44a196369 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -48,6 +48,7 @@ RandKSpaceSpikeNoise, RandRicianNoise, RandScaleIntensity, + RandScaleIntensityFixedMean, RandShiftIntensity, RandStdShiftIntensity, SavitzkyGolaySmooth, @@ -108,6 +109,9 @@ "StdShiftIntensityDict", "RandScaleIntensityD", "RandScaleIntensityDict", + "RandScaleIntensityFixedMeand", + "RandScaleIntensityFixedMeanDict", + "RandScaleIntensityFixedMeanD", "RandStdShiftIntensityD", "RandStdShiftIntensityDict", "RandBiasFieldD", @@ -623,6 +627,71 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N return d +class RandScaleIntensityFixedMeand(RandomizableTransform, MapTransform): + """ + Dictionary-based version :py:class:`monai.transforms.RandScaleIntensity`. + Subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + """ + + backend = RandScaleIntensityFixedMean.backend + + def __init__( + self, + keys: KeysCollection, + factors: Sequence[float, float] | float, + fixed_mean: bool = True, + preserve_range: bool = False, + prob: float = 0.1, + dtype: DtypeLike = np.float32, + allow_missing_keys: bool = False, + ) -> None: + """ + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + factors: factor range to randomly scale by ``v = v * (1 + factor)``. + if single number, factor value is picked from (-factors, factors). + preserve_range: clips the output array/tensor to the range of the input array/tensor + fixed_mean: subtract the mean intensity before scaling with `factor`, then add the same value after scaling + to ensure that the output has the same mean as the input. + channel_wise: if True, scale on each channel separately. `preserve_range` and `fixed_mean` are also applied + on each channel separately if `channel_wise` is True. Please ensure that the first dimension represents the + channel of the image if True. + dtype: output data type, if None, same as input image. defaults to float32. + allow_missing_keys: don't raise exception if key is missing. + + """ + MapTransform.__init__(self, keys, allow_missing_keys) + RandomizableTransform.__init__(self, prob) + self.fixed_mean = fixed_mean + self.preserve_range = preserve_range + self.scaler = RandScaleIntensityFixedMean( + factors=factors, fixed_mean=self.fixed_mean, preserve_range=preserve_range, dtype=dtype, prob=1.0 + ) + + def set_random_state( + self, seed: int | None = None, state: np.random.RandomState | None = None + ) -> "RandScaleIntensityFixedMean": + super().set_random_state(seed, state) + self.scaler.set_random_state(seed, state) + return self + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: + d = dict(data) + self.randomize(None) + if not self._do_transform: + for key in self.key_iterator(d): + d[key] = convert_to_tensor(d[key], track_meta=get_track_meta()) + return d + + # all the keys share the same random scale factor + self.scaler.randomize(None) + for key in self.key_iterator(d): + d[key] = self.scaler(d[key], randomize=False) + return d + + class RandBiasFieldd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandBiasField`. @@ -798,7 +867,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N class AdjustContrastd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.AdjustContrast`. - Changes image intensity by gamma. Each pixel/voxel intensity is updated as: + Changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: `x = ((x - min) / intensity_range) ^ gamma * intensity_range + min` @@ -806,14 +875,25 @@ class AdjustContrastd(MapTransform): keys: keys of the corresponding items to be transformed. See also: monai.transforms.MapTransform gamma: gamma value to adjust the contrast as function. + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input allow_missing_keys: don't raise exception if key is missing. """ backend = AdjustContrast.backend - def __init__(self, keys: KeysCollection, gamma: float, allow_missing_keys: bool = False) -> None: + def __init__( + self, + keys: KeysCollection, + gamma: float, + invert_image: bool = False, + retain_stats: bool = False, + allow_missing_keys: bool = False, + ) -> None: super().__init__(keys, allow_missing_keys) - self.adjuster = AdjustContrast(gamma) + self.adjuster = AdjustContrast(gamma, invert_image, retain_stats) def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, NdarrayOrTensor]: d = dict(data) @@ -825,7 +905,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N class RandAdjustContrastd(RandomizableTransform, MapTransform): """ Dictionary-based version :py:class:`monai.transforms.RandAdjustContrast`. - Randomly changes image intensity by gamma. Each pixel/voxel intensity is updated as: + Randomly changes image intensity with gamma transform. Each pixel/voxel intensity is updated as: `x = ((x - min) / intensity_range) ^ gamma * intensity_range + min` @@ -835,6 +915,10 @@ class RandAdjustContrastd(RandomizableTransform, MapTransform): prob: Probability of adjustment. gamma: Range of gamma values. If single number, value is picked from (0.5, gamma), default is (0.5, 4.5). + invert_image: multiplies all intensity values with -1 before gamma transform and again after gamma transform + retain_stats: applies a scaling factor and an offset to all intensity values after gamma transform to ensure + that the output intensity distribution has the same mean and standard deviation as the intensity + distribution of the input allow_missing_keys: don't raise exception if key is missing. """ @@ -845,11 +929,14 @@ def __init__( keys: KeysCollection, prob: float = 0.1, gamma: tuple[float, float] | float = (0.5, 4.5), + invert_image: bool = False, + retain_stats: bool = False, allow_missing_keys: bool = False, ) -> None: MapTransform.__init__(self, keys, allow_missing_keys) RandomizableTransform.__init__(self, prob) - self.adjuster = RandAdjustContrast(gamma=gamma, prob=1.0) + self.adjuster = RandAdjustContrast(gamma=gamma, prob=1.0, invert_image=invert_image, retain_stats=retain_stats) + self.invert_image = invert_image def set_random_state( self, seed: int | None = None, state: np.random.RandomState | None = None @@ -1801,6 +1888,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> dict[Hashable, N RandBiasFieldD = RandBiasFieldDict = RandBiasFieldd ScaleIntensityD = ScaleIntensityDict = ScaleIntensityd RandScaleIntensityD = RandScaleIntensityDict = RandScaleIntensityd +RandScaleIntensityFixedMeanD = RandScaleIntensityFixedMeanDict = RandScaleIntensityFixedMeand NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd ThresholdIntensityD = ThresholdIntensityDict = ThresholdIntensityd ScaleIntensityRangeD = ScaleIntensityRangeDict = ScaleIntensityRanged diff --git a/tests/test_adjust_contrast.py b/tests/test_adjust_contrast.py index c239f43346..9fa0247115 100644 --- a/tests/test_adjust_contrast.py +++ b/tests/test_adjust_contrast.py @@ -19,29 +19,51 @@ from monai.transforms import AdjustContrast from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASE_1 = [1.0] +TESTS = [] +for invert_image in (True, False): + for retain_stats in (True, False): + TEST_CASE_1 = [1.0, invert_image, retain_stats] + TEST_CASE_2 = [0.5, invert_image, retain_stats] + TEST_CASE_3 = [4.5, invert_image, retain_stats] -TEST_CASE_2 = [0.5] - -TEST_CASE_3 = [4.5] + TESTS.extend([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) class TestAdjustContrast(NumpyImageTestCase2D): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_correct_results(self, gamma): - adjuster = AdjustContrast(gamma=gamma) + @parameterized.expand(TESTS) + def test_correct_results(self, gamma, invert_image, retain_stats): + adjuster = AdjustContrast(gamma=gamma, invert_image=invert_image, retain_stats=retain_stats) for p in TEST_NDARRAYS: im = p(self.imt) result = adjuster(im) self.assertTrue(type(im), type(result)) - if gamma == 1.0: + if False: # gamma == 1.0: expected = self.imt else: + if invert_image: + self.imt = -self.imt + + if retain_stats: + mn = self.imt.mean() + sd = self.imt.std() + epsilon = 1e-7 img_min = self.imt.min() img_range = self.imt.max() - img_min + expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(result, expected, rtol=1e-05, type_test="tensor") + + if retain_stats: + # zero mean and normalize + expected = expected - expected.mean() + expected = expected / (expected.std() + 1e-8) + # restore old mean and standard deviation + expected = sd * expected + mn + + if invert_image: + expected = -expected + + assert_allclose(result, expected, atol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_adjust_contrastd.py b/tests/test_adjust_contrastd.py index 6de2658a5b..4a671ef7be 100644 --- a/tests/test_adjust_contrastd.py +++ b/tests/test_adjust_contrastd.py @@ -19,27 +19,45 @@ from monai.transforms import AdjustContrastd from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose -TEST_CASE_1 = [1.0] +TESTS = [] +for invert_image in (True, False): + for retain_stats in (True, False): + TEST_CASE_1 = [1.0, invert_image, retain_stats] + TEST_CASE_2 = [0.5, invert_image, retain_stats] + TEST_CASE_3 = [4.5, invert_image, retain_stats] -TEST_CASE_2 = [0.5] - -TEST_CASE_3 = [4.5] + TESTS.extend([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) class TestAdjustContrastd(NumpyImageTestCase2D): - @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3]) - def test_correct_results(self, gamma): - adjuster = AdjustContrastd("img", gamma=gamma) + @parameterized.expand(TESTS) + def test_correct_results(self, gamma, invert_image, retain_stats): + adjuster = AdjustContrastd("img", gamma=gamma, invert_image=invert_image, retain_stats=retain_stats) for p in TEST_NDARRAYS: result = adjuster({"img": p(self.imt)}) - if gamma == 1.0: - expected = self.imt - else: - epsilon = 1e-7 - img_min = self.imt.min() - img_range = self.imt.max() - img_min - expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min - assert_allclose(result["img"], expected, rtol=1e-05, type_test="tensor") + if invert_image: + self.imt = -self.imt + + if retain_stats: + mn = self.imt.mean() + sd = self.imt.std() + + epsilon = 1e-7 + img_min = self.imt.min() + img_range = self.imt.max() - img_min + + expected = np.power(((self.imt - img_min) / float(img_range + epsilon)), gamma) * img_range + img_min + + if retain_stats: + # zero mean and normalize + expected = expected - expected.mean() + expected = expected / (expected.std() + 1e-8) + # restore old mean and standard deviation + expected = sd * expected + mn + + if invert_image: + expected = -expected + assert_allclose(result["img"], expected, atol=1e-05, type_test="tensor") if __name__ == "__main__": diff --git a/tests/test_rand_scale_intensity_fixed_mean.py b/tests/test_rand_scale_intensity_fixed_mean.py new file mode 100644 index 0000000000..f43adab32f --- /dev/null +++ b/tests/test_rand_scale_intensity_fixed_mean.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandScaleIntensityFixedMean +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestRandScaleIntensity(NumpyImageTestCase2D): + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_value(self, p): + scaler = RandScaleIntensityFixedMean(prob=1.0, factors=0.5) + scaler.set_random_state(seed=0) + im = p(self.imt) + result = scaler(im) + np.random.seed(0) + # simulate the randomize() of transform + np.random.random() + mn = im.mean() + im = im - mn + expected = (1 + np.random.uniform(low=-0.5, high=0.5)) * im + expected = expected + mn + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rand_scale_intensity_fixed_meand.py b/tests/test_rand_scale_intensity_fixed_meand.py new file mode 100644 index 0000000000..c85c764a55 --- /dev/null +++ b/tests/test_rand_scale_intensity_fixed_meand.py @@ -0,0 +1,41 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np + +from monai.transforms import RandScaleIntensityFixedMeand +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestRandScaleIntensityFixedMeand(NumpyImageTestCase2D): + def test_value(self): + key = "img" + for p in TEST_NDARRAYS: + scaler = RandScaleIntensityFixedMeand(keys=[key], factors=0.5, prob=1.0) + scaler.set_random_state(seed=0) + result = scaler({key: p(self.imt)}) + np.random.seed(0) + # simulate the randomize function of transform + np.random.random() + im = self.imt + mn = im.mean() + im = im - mn + expected = (1 + np.random.uniform(low=-0.5, high=0.5)) * im + expected = expected + mn + assert_allclose(result[key], p(expected), type_test="tensor", atol=1e-6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scale_intensity_fixed_mean.py b/tests/test_scale_intensity_fixed_mean.py new file mode 100644 index 0000000000..afbcd46141 --- /dev/null +++ b/tests/test_scale_intensity_fixed_mean.py @@ -0,0 +1,94 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import ScaleIntensityFixedMean +from tests.utils import TEST_NDARRAYS, NumpyImageTestCase2D, assert_allclose + + +class TestScaleIntensityFixedMean(NumpyImageTestCase2D): + def test_factor_scale(self): + for p in TEST_NDARRAYS: + scaler = ScaleIntensityFixedMean(factor=0.1, fixed_mean=False) + result = scaler(p(self.imt)) + expected = p((self.imt * (1 + 0.1)).astype(np.float32)) + assert_allclose(result, p(expected), type_test="tensor", rtol=1e-7, atol=0) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_preserve_range(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean( + factor=factor, preserve_range=True, channel_wise=channel_wise, fixed_mean=False + ) + im = p(self.imt) + result = scaler(im) + + if False: # channel_wise: + out = [] + for d in im: + clip_min = d.min() + clip_max = d.max() + d = (1 + factor) * d + d[d < clip_min] = clip_min + d[d > clip_max] = clip_max + out.append(d) + expected = p(out) + else: + clip_min = im.min() + clip_max = im.max() + im = (1 + factor) * im + im[im < clip_min] = clip_min + im[im > clip_max] = clip_max + expected = im + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_fixed_mean(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean(factor=factor, fixed_mean=True, channel_wise=channel_wise) + im = p(self.imt) + result = scaler(im) + mn = im.mean() + im = im - mn + expected = (1 + factor) * im + expected = expected + mn + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + @parameterized.expand([[p] for p in TEST_NDARRAYS]) + def test_fixed_mean_preserve_range(self, p): + for channel_wise in [False, True]: + factor = 0.9 + scaler = ScaleIntensityFixedMean( + factor=factor, preserve_range=True, fixed_mean=True, channel_wise=channel_wise + ) + im = p(self.imt) + clip_min = im.min() + clip_max = im.max() + result = scaler(im) + mn = im.mean() + im = im - mn + expected = (1 + factor) * im + expected = expected + mn + expected[expected < clip_min] = clip_min + expected[expected > clip_max] = clip_max + assert_allclose(result, expected, type_test="tensor", atol=1e-7) + + +if __name__ == "__main__": + unittest.main() From b9b07bf5704a75e530ef87d31c1fe1d517319d97 Mon Sep 17 00:00:00 2001 From: Aaron Kujawa Date: Tue, 23 May 2023 11:39:43 +0100 Subject: [PATCH 24/24] Updated docs transforms.rst --- docs/source/transforms.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index e045a7e741..0255789b68 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -317,6 +317,18 @@ Intensity :members: :special-members: __call__ +`ScaleIntensityFixedMean` +""""""""""""""""""""""""" +.. autoclass:: ScaleIntensityFixedMean + :members: + :special-members: __call__ + +`RandScaleIntensityFixedMean` +""""""""""""""""""""""""""""" +.. autoclass:: RandScaleIntensityFixedMean + :members: + :special-members: __call__ + `NormalizeIntensity` """""""""""""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/NormalizeIntensity.png @@ -1375,6 +1387,12 @@ Intensity (Dict) :members: :special-members: __call__ +`RandScaleIntensityFixedMeand` +""""""""""""""""""""""""""""""" +.. autoclass:: RandScaleIntensityFixedMeand + :members: + :special-members: __call__ + `NormalizeIntensityd` """"""""""""""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/NormalizeIntensityd.png