diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 804346b290..37a4a4cf44 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -708,6 +708,22 @@ Spatial :members: :special-members: __call__ + +Smooth Field +^^^^^^^^^^^^ + +`RandSmoothFieldAdjustContrast` +""""""""""""""""""""""""""""""" +.. autoclass:: RandSmoothFieldAdjustContrast + :members: + :special-members: __call__ + +`RandSmoothFieldAdjustIntensity` +"""""""""""""""""""""""""""""""" +.. autoclass:: RandSmoothFieldAdjustIntensity + :members: + :special-members: __call__ + Utility ^^^^^^^ @@ -1474,6 +1490,21 @@ Spatial (Dict) :members: :special-members: __call__ +Smooth Field (Dict) +^^^^^^^^^^^^^^^^^^^ + +`RandSmoothFieldAdjustContrastd` +"""""""""""""""""""""""""""""""" +.. autoclass:: RandSmoothFieldAdjustContrastd + :members: + :special-members: __call__ + +`RandSmoothFieldAdjustIntensityd` +""""""""""""""""""""""""""""""""" +.. autoclass:: RandSmoothFieldAdjustIntensityd + :members: + :special-members: __call__ + Utility (Dict) ^^^^^^^^^^^^^^ diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index cf52cde264..3f7e53f514 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -274,6 +274,8 @@ VoteEnsembled, VoteEnsembleDict, ) +from .smooth_field.array import RandSmoothFieldAdjustContrast, RandSmoothFieldAdjustIntensity, SmoothField +from .smooth_field.dictionary import RandSmoothFieldAdjustContrastd, RandSmoothFieldAdjustIntensityd from .spatial.array import ( AddCoordinateChannels, Affine, diff --git a/monai/transforms/smooth_field/__init__.py b/monai/transforms/smooth_field/__init__.py new file mode 100644 index 0000000000..14ae193634 --- /dev/null +++ b/monai/transforms/smooth_field/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2020 - 2021 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. diff --git a/monai/transforms/smooth_field/array.py b/monai/transforms/smooth_field/array.py new file mode 100644 index 0000000000..b8016dd3fd --- /dev/null +++ b/monai/transforms/smooth_field/array.py @@ -0,0 +1,226 @@ +# Copyright 2020 - 2021 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. + +"""Transforms using a smooth spatial field generated by interpolating from smaller randomized fields.""" + +from typing import Any, Optional, Sequence, Union + +import numpy as np + +import monai +from monai.transforms.spatial.array import Resize +from monai.transforms.transform import Randomizable, RandomizableTransform, Transform +from monai.transforms.utils import rescale_array +from monai.utils import InterpolateMode, ensure_tuple +from monai.utils.enums import TransformBackends +from monai.utils.type_conversion import convert_to_dst_type + +__all__ = ["SmoothField", "RandSmoothFieldAdjustContrast", "RandSmoothFieldAdjustIntensity"] + + +class SmoothField(Randomizable): + """ + Generate a smooth field array by defining a smaller randomized field and then resizing to the desired size. This + exploits interpolation to create a smoothly varying field used for other applications. + + Args: + spatial_size: final output size of the array + rand_size: size of the randomized field to start from + padder: optional transform to add padding to the randomized field + mode: interpolation mode to use when upsampling + align_corners: if True align the corners when upsampling field + low: low value for randomized field + high: high value for randomized field + channels: number of channels of final output + """ + + def __init__( + self, + spatial_size: Union[Sequence[int], int], + rand_size: Union[Sequence[int], int], + padder: Optional[Transform] = None, + mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + align_corners: Optional[bool] = None, + low: float = -1.0, + high: float = 1.0, + channels: int = 1, + ): + self.resizer: Transform = Resize(spatial_size, mode=mode, align_corners=align_corners) + self.rand_size: tuple = ensure_tuple(rand_size) + self.padder: Optional[Transform] = padder + self.field: Optional[np.ndarray] = None + self.low: float = low + self.high: float = high + self.channels: int = channels + + def randomize(self, data: Optional[Any] = None) -> None: + self.field = self.R.uniform(self.low, self.high, (self.channels,) + self.rand_size) # type: ignore + if self.padder is not None: + self.field = self.padder(self.field) + + def __call__(self): + resized_field = self.resizer(self.field) + + return rescale_array(resized_field, self.field.min(), self.field.max()) + + +class RandSmoothFieldAdjustContrast(RandomizableTransform): + """ + Randomly adjust the contrast of input images by calculating a randomized smooth field for each invocation. This + uses SmoothFieldAdjustContrast and SmoothField internally. + + Args: + spatial_size: size of input array's spatial dimensions + rand_size: size of the randomized field to start from + padder: optional transform to add padding to the randomized field + mode: interpolation mode to use when upsampling + align_corners: if True align the corners when upsampling field + prob: probability transform is applied + gamma: (min, max) range for exponential field + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + spatial_size: Union[Sequence[int], int], + rand_size: Union[Sequence[int], int], + padder: Optional[Transform] = None, + mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + align_corners: Optional[bool] = None, + prob: float = 0.1, + gamma: Union[Sequence[float], float] = (0.5, 4.5), + ): + super().__init__(prob) + + if isinstance(gamma, (int, float)): + self.gamma = (0.5, gamma) + else: + if len(gamma) != 2: + raise ValueError("Argument `gamma` should be a number or pair of numbers.") + + self.gamma = (min(gamma), max(gamma)) + + self.sfield = SmoothField(spatial_size, rand_size, padder, mode, align_corners, self.gamma[0], self.gamma[1]) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandSmoothFieldAdjustContrast": + super().set_random_state(seed, state) + self.sfield.set_random_state(seed, state) + return self + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + + if self._do_transform: + self.sfield.randomize() + + def __call__(self, img: np.ndarray, randomize: bool = True): + """ + Apply the transform to `img`, if `randomize` randomizing the smooth field otherwise reusing the previous. + """ + if randomize: + self.randomize() + + if not self._do_transform: + return img + + img_min = img.min() + img_max = img.max() + img_rng = img_max - img_min + + field = self.sfield() + field, *_ = convert_to_dst_type(field, img) + + img = (img - img_min) / max(img_rng, 1e-10) # rescale to unit values + img = img ** field # contrast is changed by raising image data to a power, in this case the field + + out = (img * img_rng) + img_min # rescale back to the original image value range + + out, *_ = convert_to_dst_type(out, img, img.dtype) + + return out + + +class RandSmoothFieldAdjustIntensity(RandomizableTransform): + """ + Randomly adjust the intensity of input images by calculating a randomized smooth field for each invocation. This + uses SmoothField internally. + + Args: + spatial_size: size of input array + rand_size: size of the randomized field to start from + padder: optional transform to add padding to the randomized field + mode: interpolation mode to use when upsampling + align_corners: if True align the corners when upsampling field + prob: probability transform is applied + gamma: (min, max) range of intensity multipliers + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + spatial_size: Union[Sequence[int], int], + rand_size: Union[Sequence[int], int], + padder: Optional[Transform] = None, + mode: Union[monai.utils.InterpolateMode, str] = monai.utils.InterpolateMode.AREA, + align_corners: Optional[bool] = None, + prob: float = 0.1, + gamma: Union[Sequence[float], float] = (0.1, 1.0), + ): + super().__init__(prob) + + if isinstance(gamma, (int, float)): + self.gamma = (0.5, gamma) + else: + if len(gamma) != 2: + raise ValueError("Argument `gamma` should be a number or pair of numbers.") + + self.gamma = (min(gamma), max(gamma)) + + self.sfield = SmoothField(spatial_size, rand_size, padder, mode, align_corners, self.gamma[0], self.gamma[1]) + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandSmoothFieldAdjustIntensity": + super().set_random_state(seed, state) + self.sfield.set_random_state(seed, state) + return self + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + + if self._do_transform: + self.sfield.randomize() + + def __call__(self, img: np.ndarray, randomize: bool = True): + """ + Apply the transform to `img`, if `randomize` randomizing the smooth field otherwise reusing the previous. + """ + + if randomize: + self.randomize() + + if not self._do_transform: + return img + + img_min = img.min() + img_max = img.max() + + field = self.sfield() + rfield, *_ = convert_to_dst_type(field, img) + + out = img * rfield + out, *_ = convert_to_dst_type(out, img, img.dtype) + + return out diff --git a/monai/transforms/smooth_field/dictionary.py b/monai/transforms/smooth_field/dictionary.py new file mode 100644 index 0000000000..43663930ad --- /dev/null +++ b/monai/transforms/smooth_field/dictionary.py @@ -0,0 +1,171 @@ +# Copyright 2020 - 2021 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 typing import Any, Hashable, Mapping, Optional, Sequence, Union + +import numpy as np + +from monai.config import KeysCollection +from monai.transforms.smooth_field.array import RandSmoothFieldAdjustContrast, RandSmoothFieldAdjustIntensity +from monai.transforms.transform import MapTransform, RandomizableTransform, Transform +from monai.utils import InterpolateMode +from monai.utils.enums import TransformBackends + +__all__ = ["RandSmoothFieldAdjustContrastd", "RandSmoothFieldAdjustIntensityd"] + + +class RandSmoothFieldAdjustContrastd(RandomizableTransform, MapTransform): + """ + Dictionary version of RandSmoothFieldAdjustContrast. The field is randomized once per invocation by default so the + same field is applied to every selected key. + + Args: + keys: key names to apply the augment to + spatial_size: size of input arrays, all arrays stated in `keys` must have same dimensions + rand_size: size of the randomized field to start from + padder: optional transform to add padding to the randomized field + mode: interpolation mode to use when upsampling + align_corners: if True align the corners when upsampling field + prob: probability transform is applied + gamma: (min, max) range for exponential field + apply_same_field: if True, apply the same field to each key, otherwise randomize individually + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + keys: KeysCollection, + spatial_size: Union[Sequence[int], int], + rand_size: Union[Sequence[int], int], + padder: Optional[Transform] = None, + mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + align_corners: Optional[bool] = None, + prob: float = 0.1, + gamma: Union[Sequence[float], float] = (0.5, 4.5), + apply_same_field: bool = True, + ): + RandomizableTransform.__init__(self, prob) + MapTransform.__init__(self, keys) + + self.trans = RandSmoothFieldAdjustContrast( + spatial_size=spatial_size, + rand_size=rand_size, + padder=padder, + mode=mode, + align_corners=align_corners, + prob=1.0, + gamma=gamma, + ) + self.apply_same_field = apply_same_field + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandSmoothFieldAdjustContrastd": + super().set_random_state(seed, state) + self.trans.set_random_state(seed, state) + return self + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + + if self._do_transform: + self.trans.randomize() + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Mapping[Hashable, np.ndarray]: + self.randomize() + + if not self._do_transform: + return data + + d = dict(data) + + for key in self.key_iterator(d): + if not self.apply_same_field: + self.randomize() # new field for every key + + d[key] = self.trans(d[key], False) + + return d + + +class RandSmoothFieldAdjustIntensityd(RandomizableTransform, MapTransform): + """ + Dictionary version of RandSmoothFieldAdjustIntensity. The field is randomized once per invocation by default so + the same field is applied to every selected key. + + Args: + keys: key names to apply the augment to + spatial_size: size of input arrays, all arrays stated in `keys` must have same dimensions + rand_size: size of the randomized field to start from + padder: optional transform to add padding to the randomized field + mode: interpolation mode to use when upsampling + align_corners: if True align the corners when upsampling field + prob: probability transform is applied + gamma: (min, max) range of intensity multipliers + apply_same_field: if True, apply the same field to each key, otherwise randomize individually + """ + + backend = [TransformBackends.TORCH, TransformBackends.NUMPY] + + def __init__( + self, + keys: KeysCollection, + spatial_size: Union[Sequence[int], int], + rand_size: Union[Sequence[int], int], + padder: Optional[Transform] = None, + mode: Union[InterpolateMode, str] = InterpolateMode.AREA, + align_corners: Optional[bool] = None, + prob: float = 0.1, + gamma: Union[Sequence[float], float] = (0.1, 1.0), + apply_same_field: bool = True, + ): + RandomizableTransform.__init__(self, prob) + MapTransform.__init__(self, keys) + + self.trans = RandSmoothFieldAdjustIntensity( + spatial_size=spatial_size, + rand_size=rand_size, + padder=padder, + mode=mode, + align_corners=align_corners, + prob=1.0, + gamma=gamma, + ) + self.apply_same_field = apply_same_field + + def set_random_state( + self, seed: Optional[int] = None, state: Optional[np.random.RandomState] = None + ) -> "RandSmoothFieldAdjustIntensityd": + super().set_random_state(seed, state) + self.trans.set_random_state(seed, state) + return self + + def randomize(self, data: Optional[Any] = None) -> None: + super().randomize(None) + self.trans.randomize() + + def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Mapping[Hashable, np.ndarray]: + self.randomize() + + if not self._do_transform: + return data + + d = dict(data) + + for key in self.key_iterator(d): + if not self.apply_same_field: + self.randomize() # new field for every key + + d[key] = self.trans(d[key], False) + + return d diff --git a/tests/test_smooth_field.py b/tests/test_smooth_field.py new file mode 100644 index 0000000000..761cc9e5fa --- /dev/null +++ b/tests/test_smooth_field.py @@ -0,0 +1,75 @@ +# Copyright 2020 - 2021 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. + +import unittest + +import numpy as np +from parameterized import parameterized + +from monai.transforms import RandSmoothFieldAdjustContrastd, RandSmoothFieldAdjustIntensityd +from tests.utils import TEST_NDARRAYS, assert_allclose, is_tf32_env + +_rtol = 5e-3 if is_tf32_env() else 1e-4 + +INPUT_SHAPE1 = (1, 8, 8) +INPUT_SHAPE2 = (2, 8, 8) + +TESTS_CONTRAST = [] +TESTS_INTENSITY = [] + +for p in TEST_NDARRAYS: + TESTS_CONTRAST += [ + ( + {"keys": ("test",), "spatial_size": INPUT_SHAPE1[1:], "rand_size": (4, 4), "prob": 1.0}, + {"test": p(np.ones(INPUT_SHAPE1, np.float32))}, + {"test": p(np.ones(INPUT_SHAPE1, np.float32))}, + ), + ( + {"keys": ("test",), "spatial_size": INPUT_SHAPE2[1:], "rand_size": (4, 4), "prob": 1.0}, + {"test": p(np.ones(INPUT_SHAPE2, np.float32))}, + {"test": p(np.ones(INPUT_SHAPE2, np.float32))}, + ), + ] + + TESTS_INTENSITY += [ + ( + {"keys": ("test",), "spatial_size": INPUT_SHAPE1[1:], "rand_size": (4, 4), "prob": 1.0, "gamma": (1, 1)}, + {"test": p(np.ones(INPUT_SHAPE1, np.float32))}, + {"test": p(np.ones(INPUT_SHAPE1, np.float32))}, + ), + ( + {"keys": ("test",), "spatial_size": INPUT_SHAPE2[1:], "rand_size": (4, 4), "prob": 1.0, "gamma": (1, 1)}, + {"test": p(np.ones(INPUT_SHAPE2, np.float32))}, + {"test": p(np.ones(INPUT_SHAPE2, np.float32))}, + ), + ] + + +class TestSmoothField(unittest.TestCase): + @parameterized.expand(TESTS_CONTRAST) + def test_rand_smooth_field_adjust_contrastd(self, input_param, input_data, expected_val): + g = RandSmoothFieldAdjustContrastd(**input_param) + g.set_random_state(123) + + res = g(input_data) + for key, result in res.items(): + expected = expected_val[key] + assert_allclose(result, expected, rtol=_rtol, atol=5e-3) + + @parameterized.expand(TESTS_INTENSITY) + def test_rand_smooth_field_adjust_intensityd(self, input_param, input_data, expected_val): + g = RandSmoothFieldAdjustIntensityd(**input_param) + g.set_random_state(123) + + res = g(input_data) + for key, result in res.items(): + expected = expected_val[key] + assert_allclose(result, expected, rtol=_rtol, atol=5e-3)