diff --git a/docs/source/transforms.rst b/docs/source/transforms.rst index 8b1a77ea4c..653647a3fa 100644 --- a/docs/source/transforms.rst +++ b/docs/source/transforms.rst @@ -1217,6 +1217,14 @@ Intensity (Dict) :members: :special-members: __call__ +`SavitzkyGolaySmoothd` +"""""""""""""""""""""" +.. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/SavitzkyGolaySmoothd.png + :alt: example of SavitzkyGolaySmoothd +.. autoclass:: SavitzkyGolaySmoothd + :members: + :special-members: __call__ + `GaussianSmoothd` """"""""""""""""" .. image:: https://github.com/Project-MONAI/DocImages/raw/main/transforms/GaussianSmoothd.png diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py index 3f7e53f514..720c35820e 100644 --- a/monai/transforms/__init__.py +++ b/monai/transforms/__init__.py @@ -178,6 +178,9 @@ RandStdShiftIntensityd, RandStdShiftIntensityD, RandStdShiftIntensityDict, + SavitzkyGolaySmoothd, + SavitzkyGolaySmoothD, + SavitzkyGolaySmoothDict, ScaleIntensityd, ScaleIntensityD, ScaleIntensityDict, diff --git a/monai/transforms/intensity/dictionary.py b/monai/transforms/intensity/dictionary.py index 683d75763f..1cbb9f92e1 100644 --- a/monai/transforms/intensity/dictionary.py +++ b/monai/transforms/intensity/dictionary.py @@ -44,6 +44,7 @@ RandScaleIntensity, RandShiftIntensity, RandStdShiftIntensity, + SavitzkyGolaySmooth, ScaleIntensity, ScaleIntensityRange, ScaleIntensityRangePercentiles, @@ -73,6 +74,7 @@ "RandAdjustContrastd", "ScaleIntensityRangePercentilesd", "MaskIntensityd", + "SavitzkyGolaySmoothd", "GaussianSmoothd", "RandGaussianSmoothd", "GaussianSharpend", @@ -115,6 +117,8 @@ "ScaleIntensityRangePercentilesDict", "MaskIntensityD", "MaskIntensityDict", + "SavitzkyGolaySmoothD", + "SavitzkyGolaySmoothDict", "GaussianSmoothD", "GaussianSmoothDict", "RandGaussianSmoothD", @@ -917,6 +921,43 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N return d +class SavitzkyGolaySmoothd(MapTransform): + """ + Dictionary-based wrapper of :py:class:`monai.transforms.SavitzkyGolaySmooth`. + + Args: + keys: keys of the corresponding items to be transformed. + See also: :py:class:`monai.transforms.compose.MapTransform` + window_length: length of the filter window, must be a positive odd integer. + order: order of the polynomial to fit to each window, must be less than ``window_length``. + axis: optional axis along which to apply the filter kernel. Default 1 (first spatial dimension). + mode: optional padding mode, passed to convolution class. ``'zeros'``, ``'reflect'``, ``'replicate'`` + or ``'circular'``. default: ``'zeros'``. See ``torch.nn.Conv1d()`` for more information. + allow_missing_keys: don't raise exception if key is missing. + + """ + + backend = SavitzkyGolaySmooth.backend + + def __init__( + self, + keys: KeysCollection, + window_length: int, + order: int, + axis: int = 1, + mode: str = "zeros", + allow_missing_keys: bool = False, + ) -> None: + super().__init__(keys, allow_missing_keys) + self.converter = SavitzkyGolaySmooth(window_length=window_length, order=order, axis=axis, mode=mode) + + def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, NdarrayOrTensor]: + d = dict(data) + for key in self.key_iterator(d): + d[key] = self.converter(d[key]) + return d + + class GaussianSmoothd(MapTransform): """ Dictionary-based wrapper of :py:class:`monai.transforms.GaussianSmooth`. @@ -1626,6 +1667,7 @@ def __call__(self, data: Mapping[Hashable, NdarrayOrTensor]) -> Dict[Hashable, N RandAdjustContrastD = RandAdjustContrastDict = RandAdjustContrastd ScaleIntensityRangePercentilesD = ScaleIntensityRangePercentilesDict = ScaleIntensityRangePercentilesd MaskIntensityD = MaskIntensityDict = MaskIntensityd +SavitzkyGolaySmoothD = SavitzkyGolaySmoothDict = SavitzkyGolaySmoothd GaussianSmoothD = GaussianSmoothDict = GaussianSmoothd RandGaussianSmoothD = RandGaussianSmoothDict = RandGaussianSmoothd GaussianSharpenD = GaussianSharpenDict = GaussianSharpend diff --git a/monai/transforms/utils_create_transform_ims.py b/monai/transforms/utils_create_transform_ims.py index 7dc2fbae6d..e7939b764f 100644 --- a/monai/transforms/utils_create_transform_ims.py +++ b/monai/transforms/utils_create_transform_ims.py @@ -136,6 +136,7 @@ RandScaleIntensityd, RandShiftIntensityd, RandStdShiftIntensityd, + SavitzkyGolaySmoothd, ScaleIntensityRanged, ScaleIntensityRangePercentilesd, ShiftIntensityd, @@ -526,6 +527,7 @@ def create_transform_im( create_transform_im(RandRicianNoise, dict(prob=1.0, mean=1, std=0.5), data) create_transform_im(RandRicianNoised, dict(keys=CommonKeys.IMAGE, prob=1.0, mean=1, std=0.5), data) create_transform_im(SavitzkyGolaySmooth, dict(window_length=5, order=1), data) + create_transform_im(SavitzkyGolaySmoothd, dict(keys=CommonKeys.IMAGE, window_length=5, order=1), data) create_transform_im(GibbsNoise, dict(alpha=0.8), data) create_transform_im(GibbsNoised, dict(keys=CommonKeys.IMAGE, alpha=0.8), data) create_transform_im(RandGibbsNoise, dict(prob=1.0, alpha=(0.6, 0.8)), data) diff --git a/tests/test_savitzky_golay_smooth.py b/tests/test_savitzky_golay_smooth.py index 0f398bc48f..3cd95c4d13 100644 --- a/tests/test_savitzky_golay_smooth.py +++ b/tests/test_savitzky_golay_smooth.py @@ -59,15 +59,9 @@ class TestSavitzkyGolaySmooth(unittest.TestCase): - @parameterized.expand([TEST_CASE_SINGLE_VALUE, TEST_CASE_2D_AXIS_2, TEST_CASE_SINE_SMOOTH]) - def test_value(self, arguments, image, expected_data, atol): - for p in TEST_NDARRAYS: - result = SavitzkyGolaySmooth(**arguments)(p(image.astype(np.float32))) - torch.testing.assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-4, atol=atol) - - -class TestSavitzkyGolaySmoothREP(unittest.TestCase): - @parameterized.expand([TEST_CASE_SINGLE_VALUE_REP]) + @parameterized.expand( + [TEST_CASE_SINGLE_VALUE, TEST_CASE_2D_AXIS_2, TEST_CASE_SINE_SMOOTH, TEST_CASE_SINGLE_VALUE_REP] + ) def test_value(self, arguments, image, expected_data, atol): for p in TEST_NDARRAYS: result = SavitzkyGolaySmooth(**arguments)(p(image.astype(np.float32))) diff --git a/tests/test_savitzky_golay_smoothd.py b/tests/test_savitzky_golay_smoothd.py new file mode 100644 index 0000000000..f6bab74ddd --- /dev/null +++ b/tests/test_savitzky_golay_smoothd.py @@ -0,0 +1,72 @@ +# Copyright 2020 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 +import torch +from parameterized import parameterized + +from monai.transforms import SavitzkyGolaySmoothd +from tests.utils import TEST_NDARRAYS + +# Zero-padding trivial tests + +TEST_CASE_SINGLE_VALUE = [ + {"keys": "img", "window_length": 3, "order": 1}, + np.expand_dims(np.array([1.0]), 0), # Input data: Single value + np.expand_dims(np.array([1 / 3]), 0), # Expected output: With a window length of 3 and polyorder 1 + # output should be equal to mean of 0, 1 and 0 = 1/3 (because input will be zero-padded and a linear fit performed) + 1e-5, # absolute tolerance +] + +TEST_CASE_2D_AXIS_2 = [ + {"keys": "img", "window_length": 3, "order": 1, "axis": 2}, # along axis 2 (second spatial dim) + np.expand_dims(np.ones((2, 3)), 0), + np.expand_dims(np.array([[2 / 3, 1.0, 2 / 3], [2 / 3, 1.0, 2 / 3]]), 0), + 1e-5, # absolute tolerance +] + +# Replicated-padding trivial tests + +TEST_CASE_SINGLE_VALUE_REP = [ + {"keys": "img", "window_length": 3, "order": 1, "mode": "replicate"}, + np.expand_dims(np.array([1.0]), 0), # Input data: Single value + np.expand_dims(np.array([1.0]), 0), # Expected output: With a window length of 3 and polyorder 1 + # output will be equal to mean of [1, 1, 1] = 1 (input will be nearest-neighbour-padded and a linear fit performed) + 1e-5, # absolute tolerance +] + +# Sine smoothing + +TEST_CASE_SINE_SMOOTH = [ + {"keys": "img", "window_length": 3, "order": 1}, + # Sine wave with period equal to savgol window length (windowed to reduce edge effects). + np.expand_dims(np.sin(2 * np.pi * 1 / 3 * np.arange(100)) * np.hanning(100), 0), + # Should be smoothed out to zeros + np.expand_dims(np.zeros(100), 0), + # tolerance chosen by examining output of SciPy.signal.savgol_filter() when provided the above input + 2e-2, # absolute tolerance +] + + +class TestSavitzkyGolaySmoothd(unittest.TestCase): + @parameterized.expand( + [TEST_CASE_SINGLE_VALUE, TEST_CASE_2D_AXIS_2, TEST_CASE_SINE_SMOOTH, TEST_CASE_SINGLE_VALUE_REP] + ) + def test_value(self, arguments, image, expected_data, atol): + for p in TEST_NDARRAYS: + result = SavitzkyGolaySmoothd(**arguments)({"img": p(image.astype(np.float32))})["img"] + torch.testing.assert_allclose(result, p(expected_data.astype(np.float32)), rtol=1e-4, atol=atol) + + +if __name__ == "__main__": + unittest.main()