Repository: Project-MONAI/MONAI

Issue:
Add dictionary version transform SavitzkyGolaySmoothd
**Is your feature request related to a problem? Please describe.**
Currently, we have the array version intensity transform `SavitzkyGolaySmooth`, would be nice to add the dict version.




Discussion:

Resolving patch:
diff --git a/monai/transforms/__init__.py b/monai/transforms/__init__.py
--- 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
--- 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
--- 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)


Test patch:
diff --git a/tests/test_savitzky_golay_smooth.py b/tests/test_savitzky_golay_smooth.py
--- 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
--- /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()