Repository: Project-MONAI/MONAI

Issue:
Add std based shift intensity transform
The current `ShiftIntensity` can only support the transform that shift a fixed value for the image. Here I wish to add a new transform function that can shift `a value * the standard deviation of an image`.
I'll submit a PR to implement this transform function


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
@@ -78,11 +78,13 @@
     RandHistogramShift,
     RandScaleIntensity,
     RandShiftIntensity,
+    RandStdShiftIntensity,
     SavitzkyGolaySmooth,
     ScaleIntensity,
     ScaleIntensityRange,
     ScaleIntensityRangePercentiles,
     ShiftIntensity,
+    StdShiftIntensity,
     ThresholdIntensity,
 )
 from .intensity.dictionary import (
@@ -122,6 +124,9 @@
     RandShiftIntensityd,
     RandShiftIntensityD,
     RandShiftIntensityDict,
+    RandStdShiftIntensityd,
+    RandStdShiftIntensityD,
+    RandStdShiftIntensityDict,
     ScaleIntensityd,
     ScaleIntensityD,
     ScaleIntensityDict,
@@ -134,6 +139,9 @@
     ShiftIntensityd,
     ShiftIntensityD,
     ShiftIntensityDict,
+    StdShiftIntensityd,
+    StdShiftIntensityD,
+    StdShiftIntensityDict,
     ThresholdIntensityd,
     ThresholdIntensityD,
     ThresholdIntensityDict,
diff --git a/monai/transforms/intensity/array.py b/monai/transforms/intensity/array.py
--- a/monai/transforms/intensity/array.py
+++ b/monai/transforms/intensity/array.py
@@ -30,6 +30,8 @@
     "RandGaussianNoise",
     "ShiftIntensity",
     "RandShiftIntensity",
+    "StdShiftIntensity",
+    "RandStdShiftIntensity",
     "ScaleIntensity",
     "RandScaleIntensity",
     "NormalizeIntensity",
@@ -135,6 +137,94 @@ def __call__(self, img: np.ndarray) -> np.ndarray:
         return shifter(img)
 
 
+class StdShiftIntensity(Transform):
+    """
+    Shift intensity for the image with a factor and the standard deviation of the image
+    by: ``v = v + factor * std(v)``.
+    This transform can focus on only non-zero values or the entire image,
+    and can also calculate the std on each channel separately.
+
+    Args:
+        factor: factor shift by ``v = v + factor * std(v)``.
+        nonzero: whether only count non-zero values.
+        channel_wise: if True, calculate on each channel separately. Please ensure
+            that the first dimension represents the channel of the image if True.
+    """
+
+    def __init__(self, factor: float, nonzero: bool = False, channel_wise: bool = False) -> None:
+        self.factor = factor
+        self.nonzero = nonzero
+        self.channel_wise = channel_wise
+
+    def _stdshift(self, img: np.ndarray) -> np.ndarray:
+        slices = (img != 0) if self.nonzero else np.ones(img.shape, dtype=bool)
+        if not np.any(slices):
+            return img
+        offset = self.factor * np.std(img[slices])
+        img[slices] = img[slices] + offset
+        return img
+
+    def __call__(self, img: np.ndarray) -> np.ndarray:
+        """
+        Apply the transform to `img`.
+        """
+        if img.dtype != float:
+            img = img.astype(float)
+        if self.channel_wise:
+            for i, d in enumerate(img):
+                img[i] = self._stdshift(d)
+        else:
+            img = self._stdshift(img)
+        return img
+
+
+class RandStdShiftIntensity(RandomizableTransform):
+    """
+    Shift intensity for the image with a factor and the standard deviation of the image
+    by: ``v = v + factor * std(v)`` where the `factor` is randomly picked.
+    """
+
+    def __init__(
+        self,
+        factors: Union[Tuple[float, float], float],
+        prob: float = 0.1,
+        nonzero: bool = False,
+        channel_wise: bool = False,
+    ) -> None:
+        """
+        Args:
+            factors: if tuple, the randomly picked range is (min(factors), max(factors)).
+                If single number, the range is (-factors, factors).
+            prob: probability of std shift.
+            nonzero: whether only count non-zero values.
+            channel_wise: if True, calculate on each channel separately.
+
+        """
+        RandomizableTransform.__init__(self, prob)
+        if isinstance(factors, (int, float)):
+            self.factors = (min(-factors, factors), max(-factors, factors))
+        else:
+            if len(factors) != 2:
+                raise AssertionError("factors should be a number or pair of numbers.")
+            self.factors = (min(factors), max(factors))
+        self.nonzero = nonzero
+        self.channel_wise = channel_wise
+
+    def randomize(self, data: Optional[Any] = None) -> None:
+        self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1])
+        super().randomize(None)
+
+    def __call__(self, img: np.ndarray) -> np.ndarray:
+        """
+        Apply the transform to `img`.
+        """
+        self.randomize()
+        if not self._do_transform:
+            return img
+        shifter = StdShiftIntensity(factor=self.factor, nonzero=self.nonzero, channel_wise=self.channel_wise)
+        return shifter(img)
+
+
 class ScaleIntensity(Transform):
     """
     Scale the intensity of input image to the given value range (minv, maxv).
@@ -173,7 +263,7 @@ def __call__(self, img: np.ndarray) -> np.ndarray:
 class RandScaleIntensity(RandomizableTransform):
     """
     Randomly scale the intensity of input image by ``v = v * (1 + factor)`` where the `factor`
-    is randomly picked from (-factors[0], factors[0]).
+    is randomly picked.
     """
 
     def __init__(self, factors: Union[Tuple[float, float], float], prob: float = 0.1) -> None:
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
@@ -32,6 +32,7 @@
     ScaleIntensityRange,
     ScaleIntensityRangePercentiles,
     ShiftIntensity,
+    StdShiftIntensity,
     ThresholdIntensity,
 )
 from monai.transforms.transform import MapTransform, RandomizableTransform
@@ -211,6 +212,91 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
         return d
 
 
+class StdShiftIntensityd(MapTransform):
+    """
+    Dictionary-based wrapper of :py:class:`monai.transforms.StdShiftIntensity`.
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        factor: float,
+        nonzero: bool = False,
+        channel_wise: bool = False,
+        allow_missing_keys: bool = False,
+    ) -> None:
+        """
+        Args:
+            keys: keys of the corresponding items to be transformed.
+                See also: :py:class:`monai.transforms.compose.MapTransform`
+            factor: factor shift by ``v = v + factor * std(v)``.
+            nonzero: whether only count non-zero values.
+            channel_wise: if True, calculate on each channel separately. Please ensure
+                that the first dimension represents the channel of the image if True.
+            allow_missing_keys: don't raise exception if key is missing.
+        """
+        super().__init__(keys, allow_missing_keys)
+        self.shifter = StdShiftIntensity(factor, nonzero, channel_wise)
+
+    def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
+        d = dict(data)
+        for key in self.key_iterator(d):
+            d[key] = self.shifter(d[key])
+        return d
+
+
+class RandStdShiftIntensityd(RandomizableTransform, MapTransform):
+    """
+    Dictionary-based version :py:class:`monai.transforms.RandStdShiftIntensity`.
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        factors: Union[Tuple[float, float], float],
+        prob: float = 0.1,
+        nonzero: bool = False,
+        channel_wise: bool = False,
+        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: if tuple, the randomly picked range is (min(factors), max(factors)).
+                If single number, the range is (-factors, factors).
+            prob: probability of std shift.
+            nonzero: whether only count non-zero values.
+            channel_wise: if True, calculate on each channel separately.
+            allow_missing_keys: don't raise exception if key is missing.
+        """
+        MapTransform.__init__(self, keys, allow_missing_keys)
+        RandomizableTransform.__init__(self, prob)
+
+        if isinstance(factors, (int, float)):
+            self.factors = (min(-factors, factors), max(-factors, factors))
+        else:
+            if len(factors) != 2:
+                raise AssertionError("factors should be a number or pair of numbers.")
+            self.factors = (min(factors), max(factors))
+        self.nonzero = nonzero
+        self.channel_wise = channel_wise
+
+    def randomize(self, data: Optional[Any] = None) -> None:
+        self.factor = self.R.uniform(low=self.factors[0], high=self.factors[1])
+        super().randomize(None)
+
+    def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
+        d = dict(data)
+        self.randomize()
+        if not self._do_transform:
+            return d
+        shifter = StdShiftIntensity(self.factor, self.nonzero, self.channel_wise)
+        for key in self.key_iterator(d):
+            d[key] = shifter(d[key])
+        return d
+
+
 class ScaleIntensityd(MapTransform):
     """
     Dictionary-based wrapper of :py:class:`monai.transforms.ScaleIntensity`.
@@ -812,6 +898,8 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
 RandGaussianNoiseD = RandGaussianNoiseDict = RandGaussianNoised
 ShiftIntensityD = ShiftIntensityDict = ShiftIntensityd
 RandShiftIntensityD = RandShiftIntensityDict = RandShiftIntensityd
+StdShiftIntensityD = StdShiftIntensityDict = StdShiftIntensityd
+RandStdShiftIntensityD = RandStdShiftIntensityDict = RandStdShiftIntensityd
 ScaleIntensityD = ScaleIntensityDict = ScaleIntensityd
 RandScaleIntensityD = RandScaleIntensityDict = RandScaleIntensityd
 NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd


Test patch:
diff --git a/tests/test_normalize_intensity.py b/tests/test_normalize_intensity.py
--- a/tests/test_normalize_intensity.py
+++ b/tests/test_normalize_intensity.py
@@ -58,7 +58,7 @@
 class TestNormalizeIntensity(NumpyImageTestCase2D):
     def test_default(self):
         normalizer = NormalizeIntensity()
-        normalized = normalizer(self.imt)
+        normalized = normalizer(self.imt.copy())
         self.assertTrue(normalized.dtype == np.float32)
         expected = (self.imt - np.mean(self.imt)) / np.std(self.imt)
         np.testing.assert_allclose(normalized, expected, rtol=1e-5)
diff --git a/tests/test_rand_std_shift_intensity.py b/tests/test_rand_std_shift_intensity.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_std_shift_intensity.py
@@ -0,0 +1,32 @@
+# 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 monai.transforms import RandStdShiftIntensity
+from tests.utils import NumpyImageTestCase2D
+
+
+class TestRandStdShiftIntensity(NumpyImageTestCase2D):
+    def test_value(self):
+        shifter = RandStdShiftIntensity(factors=1.0, prob=1.0)
+        shifter.set_random_state(seed=0)
+        result = shifter(self.imt)
+        np.random.seed(0)
+        factor = np.random.uniform(low=-1.0, high=1.0)
+        expected = self.imt + factor * np.std(self.imt)
+        np.testing.assert_allclose(result, expected, rtol=1e-5)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_rand_std_shift_intensityd.py b/tests/test_rand_std_shift_intensityd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_std_shift_intensityd.py
@@ -0,0 +1,33 @@
+# 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 monai.transforms import RandStdShiftIntensityd
+from tests.utils import NumpyImageTestCase2D
+
+
+class TestRandStdShiftIntensityd(NumpyImageTestCase2D):
+    def test_value(self):
+        key = "img"
+        shifter = RandStdShiftIntensityd(keys=[key], factors=1.0, prob=1.0)
+        shifter.set_random_state(seed=0)
+        result = shifter({key: self.imt})
+        np.random.seed(0)
+        factor = np.random.uniform(low=-1.0, high=1.0)
+        expected = self.imt + factor * np.std(self.imt)
+        np.testing.assert_allclose(result[key], expected, rtol=1e-5)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_std_shift_intensity.py b/tests/test_std_shift_intensity.py
new file mode 100644
--- /dev/null
+++ b/tests/test_std_shift_intensity.py
@@ -0,0 +1,57 @@
+# 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 monai.transforms import ShiftIntensity, StdShiftIntensity
+from tests.utils import NumpyImageTestCase2D
+
+
+class TestStdShiftIntensity(NumpyImageTestCase2D):
+    def test_value(self):
+        factor = np.random.rand()
+        offset = np.std(self.imt) * factor
+        shifter = ShiftIntensity(offset=offset)
+        expected = shifter(self.imt)
+        std_shifter = StdShiftIntensity(factor=factor)
+        result = std_shifter(self.imt)
+        np.testing.assert_allclose(result, expected, rtol=1e-5)
+
+    def test_zerostd(self):
+        image = np.ones([2, 3, 3])
+        for nonzero in [True, False]:
+            for channel_wise in [True, False]:
+                factor = np.random.rand()
+                std_shifter = StdShiftIntensity(factor=factor, nonzero=nonzero, channel_wise=channel_wise)
+                result = std_shifter(image)
+                np.testing.assert_equal(result, image)
+
+    def test_nonzero(self):
+        image = np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]])  # std = 1
+        factor = np.random.rand()
+        std_shifter = StdShiftIntensity(factor=factor, nonzero=True)
+        result = std_shifter(image)
+        expected = np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]])
+        np.testing.assert_equal(result, expected)
+
+    def test_channel_wise(self):
+        image = np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0])))  # std: 0.5, 0
+        factor = np.random.rand()
+        std_shifter = StdShiftIntensity(factor=factor, channel_wise=True)
+        result = std_shifter(image)
+        expected = np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1])))
+        np.testing.assert_equal(result, expected)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_std_shift_intensityd.py b/tests/test_std_shift_intensityd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_std_shift_intensityd.py
@@ -0,0 +1,61 @@
+# 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 monai.transforms import ShiftIntensityd, StdShiftIntensityd
+from tests.utils import NumpyImageTestCase2D
+
+
+class TestStdShiftIntensityd(NumpyImageTestCase2D):
+    def test_value(self):
+        key = "img"
+        factor = np.random.rand()
+        offset = np.std(self.imt) * factor
+        shifter = ShiftIntensityd(keys=[key], offset=offset)
+        expected = shifter({key: self.imt})
+        std_shifter = StdShiftIntensityd(keys=[key], factor=factor)
+        result = std_shifter({key: self.imt})
+        np.testing.assert_allclose(result[key], expected[key], rtol=1e-5)
+
+    def test_zerostd(self):
+        key = "img"
+        image = np.ones([2, 3, 3])
+        for nonzero in [True, False]:
+            for channel_wise in [True, False]:
+                factor = np.random.rand()
+                std_shifter = StdShiftIntensityd(keys=[key], factor=factor, nonzero=nonzero, channel_wise=channel_wise)
+                result = std_shifter({key: image})
+                np.testing.assert_equal(result[key], image)
+
+    def test_nonzero(self):
+        key = "img"
+        image = np.asarray([[4.0, 0.0, 2.0], [0, 2, 4]])  # std = 1
+        factor = np.random.rand()
+        std_shifter = StdShiftIntensityd(keys=[key], factor=factor, nonzero=True)
+        result = std_shifter({key: image})
+        expected = np.asarray([[4 + factor, 0, 2 + factor], [0, 2 + factor, 4 + factor]])
+        np.testing.assert_equal(result[key], expected)
+
+    def test_channel_wise(self):
+        key = "img"
+        image = np.stack((np.asarray([1.0, 2.0]), np.asarray([1.0, 1.0])))  # std: 0.5, 0
+        factor = np.random.rand()
+        std_shifter = StdShiftIntensityd(keys=[key], factor=factor, channel_wise=True)
+        result = std_shifter({key: image})
+        expected = np.stack((np.asarray([1 + 0.5 * factor, 2 + 0.5 * factor]), np.asarray([1, 1])))
+        np.testing.assert_equal(result[key], expected)
+
+
+if __name__ == "__main__":
+    unittest.main()