Repository: Project-MONAI/MONAI

Issue:
Add random bias field transform
It is a useful data augmentation method for MRI.

Reference:

[1] Van Leemput, Koen, et al. "Automated model-based tissue classification of MR images of the brain." IEEE transactions on medical imaging 18.10 (1999): 897-908.

[2] Sudre, Carole H., et al. "Longitudinal segmentation of age-related white matter hyperintensities." Medical Image Analysis 38 (2017): 50-64.


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
@@ -73,6 +73,7 @@
     MaskIntensity,
     NormalizeIntensity,
     RandAdjustContrast,
+    RandBiasField,
     RandGaussianNoise,
     RandGaussianSharpen,
     RandGaussianSmooth,
@@ -107,6 +108,9 @@
     RandAdjustContrastd,
     RandAdjustContrastD,
     RandAdjustContrastDict,
+    RandBiasFieldd,
+    RandBiasFieldD,
+    RandBiasFieldDict,
     RandGaussianNoised,
     RandGaussianNoiseD,
     RandGaussianNoiseDict,
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
@@ -14,7 +14,7 @@
 """
 
 from collections.abc import Iterable
-from typing import Any, Optional, Sequence, Tuple, Union
+from typing import Any, List, Optional, Sequence, Tuple, Union
 from warnings import warn
 
 import numpy as np
@@ -32,6 +32,7 @@
     "RandShiftIntensity",
     "StdShiftIntensity",
     "RandStdShiftIntensity",
+    "RandBiasField",
     "ScaleIntensity",
     "RandScaleIntensity",
     "NormalizeIntensity",
@@ -297,6 +298,97 @@ def __call__(self, img: np.ndarray) -> np.ndarray:
         return scaler(img)
 
 
+class RandBiasField(RandomizableTransform):
+    """
+    Random bias field augmentation for MR images.
+    The bias field is considered as a linear combination of smoothly varying basis (polynomial)
+    functions, as described in `Automated Model-Based Tissue Classification of MR Images of the Brain
+    <https://ieeexplore.ieee.org/stamp/stamp.jsp?tp=&arnumber=811270>`_.
+    This implementation adapted from `NiftyNet
+    <https://github.com/NifTK/NiftyNet>`_.
+    Referred to `Longitudinal segmentation of age-related white matter hyperintensities
+    <https://www.sciencedirect.com/science/article/pii/S1361841517300257?via%3Dihub>`_.
+
+    Args:
+        degree: degree of freedom of the polynomials. The value should be no less than 1.
+            Defaults to 3.
+        coeff_range: range of the random coefficients. Defaults to (0.0, 0.1).
+        dtype: output data type, defaut to float32.
+        prob: probability to do random bias field.
+
+    """
+
+    def __init__(
+        self,
+        degree: int = 3,
+        coeff_range: Tuple[float, float] = (0.0, 0.1),
+        dtype: DtypeLike = np.float32,
+        prob: float = 1.0,
+    ) -> None:
+        RandomizableTransform.__init__(self, prob)
+        if degree < 1:
+            raise ValueError("degree should be no less than 1.")
+        self.degree = degree
+        self.coeff_range = coeff_range
+        self.dtype = dtype
+
+    def _generate_random_field(
+        self,
+        spatial_shape: Tuple[int, ...],
+        rank: int,
+        degree: int,
+        coeff: Tuple[int, ...],
+    ):
+        """
+        products of polynomials as bias field estimations
+        """
+        coeff_mat = np.zeros((degree + 1,) * rank)
+        coords = [np.linspace(-1.0, 1.0, dim, dtype=np.float32) for dim in spatial_shape]
+        if rank == 2:
+            coeff_mat[np.tril_indices(degree + 1)] = coeff
+            field = np.polynomial.legendre.leggrid2d(coords[0], coords[1], coeff_mat)
+        elif rank == 3:
+            pts: List[List[int]] = [[0, 0, 0]]
+            for i in range(degree + 1):
+                for j in range(degree + 1 - i):
+                    for k in range(degree + 1 - i - j):
+                        pts.append([i, j, k])
+            if len(pts) > 1:
+                pts = pts[1:]
+            np_pts = np.stack(pts)
+            coeff_mat[np_pts[:, 0], np_pts[:, 1], np_pts[:, 2]] = coeff
+            field = np.polynomial.legendre.leggrid3d(coords[0], coords[1], coords[2], coeff_mat)
+        else:
+            raise NotImplementedError("only supoprts 2D or 3D fields")
+        return field
+
+    def randomize(self, data: np.ndarray) -> None:
+        super().randomize(None)
+        self.spatial_shape = data.shape[1:]
+        self.rank = len(self.spatial_shape)
+        n_coeff = int(np.prod([(self.degree + k) / k for k in range(1, self.rank + 1)]))
+        self._coeff = self.R.uniform(*self.coeff_range, n_coeff)
+
+    def __call__(self, img: np.ndarray):
+        """
+        Apply the transform to `img`.
+        """
+        self.randomize(data=img)
+        if not self._do_transform:
+            return img
+        num_channels = img.shape[0]
+        _bias_fields = np.stack(
+            [
+                self._generate_random_field(
+                    spatial_shape=self.spatial_shape, rank=self.rank, degree=self.degree, coeff=self._coeff
+                )
+                for _ in range(num_channels)
+            ],
+            axis=0,
+        )
+        return (img * _bias_fields).astype(self.dtype)
+
+
 class NormalizeIntensity(Transform):
     """
     Normalize input based on provided args, using calculated mean and std if not provided.
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
@@ -28,6 +28,7 @@
     GaussianSmooth,
     MaskIntensity,
     NormalizeIntensity,
+    RandBiasField,
     ScaleIntensity,
     ScaleIntensityRange,
     ScaleIntensityRangePercentiles,
@@ -44,6 +45,9 @@
     "RandShiftIntensityd",
     "ScaleIntensityd",
     "RandScaleIntensityd",
+    "StdShiftIntensityd",
+    "RandStdShiftIntensityd",
+    "RandBiasFieldd",
     "NormalizeIntensityd",
     "ThresholdIntensityd",
     "ScaleIntensityRanged",
@@ -64,8 +68,14 @@
     "RandShiftIntensityDict",
     "ScaleIntensityD",
     "ScaleIntensityDict",
+    "StdShiftIntensityD",
+    "StdShiftIntensityDict",
     "RandScaleIntensityD",
     "RandScaleIntensityDict",
+    "RandStdShiftIntensityD",
+    "RandStdShiftIntensityDict",
+    "RandBiasFieldD",
+    "RandBiasFieldDict",
     "NormalizeIntensityD",
     "NormalizeIntensityDict",
     "ThresholdIntensityD",
@@ -381,6 +391,50 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
         return d
 
 
+class RandBiasFieldd(RandomizableTransform, MapTransform):
+    """
+    Dictionary-based version :py:class:`monai.transforms.RandBiasField`.
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        degree: int = 3,
+        coeff_range: Tuple[float, float] = (0.0, 0.1),
+        dtype: DtypeLike = np.float32,
+        prob: float = 1.0,
+        allow_missing_keys: bool = False,
+    ) -> None:
+        """
+        Args:
+            keys: keys of the corresponding items to be transformed.
+                See also: :py:class:`monai.transforms.compose.MapTransform`
+            degree: degree of freedom of the polynomials. The value should be no less than 1.
+                Defaults to 3.
+            coeff_range: range of the random coefficients. Defaults to (0.0, 0.1).
+            dtype: output data type, defaut to float32.
+            prob: probability to do random bias field.
+            allow_missing_keys: don't raise exception if key is missing.
+
+        """
+        MapTransform.__init__(self, keys, allow_missing_keys)
+        RandomizableTransform.__init__(self, prob)
+
+        self.rand_bias_field = RandBiasField(degree, coeff_range, dtype, prob)
+
+    def randomize(self, data: Optional[Any] = None) -> None:
+        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
+        for key in self.key_iterator(d):
+            d[key] = self.rand_bias_field(d[key])
+        return d
+
+
 class NormalizeIntensityd(MapTransform):
     """
     Dictionary-based wrapper of :py:class:`monai.transforms.NormalizeIntensity`.
@@ -900,6 +954,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
 RandShiftIntensityD = RandShiftIntensityDict = RandShiftIntensityd
 StdShiftIntensityD = StdShiftIntensityDict = StdShiftIntensityd
 RandStdShiftIntensityD = RandStdShiftIntensityDict = RandStdShiftIntensityd
+RandBiasFieldD = RandBiasFieldDict = RandBiasFieldd
 ScaleIntensityD = ScaleIntensityDict = ScaleIntensityd
 RandScaleIntensityD = RandScaleIntensityDict = RandScaleIntensityd
 NormalizeIntensityD = NormalizeIntensityDict = NormalizeIntensityd


Test patch:
diff --git a/tests/test_random_bias_field.py b/tests/test_random_bias_field.py
new file mode 100644
--- /dev/null
+++ b/tests/test_random_bias_field.py
@@ -0,0 +1,66 @@
+# 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 RandBiasField
+
+TEST_CASES_2D = [{}, (3, 32, 32)]
+TEST_CASES_3D = [{}, (3, 32, 32, 32)]
+TEST_CASES_2D_ZERO_RANGE = [{"coeff_range": (0.0, 0.0)}, (3, 32, 32)]
+TEST_CASES_2D_ONES = [{"coeff_range": (1.0, 1.0)}, np.asarray([[[2, -2], [2, 10]]])]
+
+
+class TestRandBiasField(unittest.TestCase):
+    @parameterized.expand(
+        [
+            TEST_CASES_2D,
+            TEST_CASES_3D,
+        ]
+    )
+    def test_output_shape(self, class_args, img_shape):
+        for degree in [1, 2, 3]:
+            bias_field = RandBiasField(degree=degree, **class_args)
+            img = np.random.rand(*img_shape)
+            output = bias_field(img)
+            np.testing.assert_equal(output.shape, img_shape)
+            np.testing.assert_equal(output.dtype, bias_field.dtype)
+
+            img_zero = np.zeros([*img_shape])
+            output_zero = bias_field(img_zero)
+            np.testing.assert_equal(output_zero, img_zero)
+
+    @parameterized.expand([TEST_CASES_2D_ZERO_RANGE])
+    def test_zero_range(self, class_args, img_shape):
+        bias_field = RandBiasField(**class_args)
+        img = np.random.rand(*img_shape)
+        output = bias_field(img)
+        np.testing.assert_equal(output, np.zeros(img_shape))
+
+    @parameterized.expand([TEST_CASES_2D_ONES])
+    def test_one_range_input(self, class_args, expected):
+        bias_field = RandBiasField(**class_args)
+        img = np.ones([1, 2, 2])
+        output = bias_field(img)
+        np.testing.assert_equal(output, expected.astype(bias_field.dtype))
+
+    def test_zero_prob(self):
+        bias_field = RandBiasField(prob=0.0)
+        img = np.random.rand(3, 32, 32)
+        output = bias_field(img)
+        np.testing.assert_equal(output, img)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_random_bias_fieldd.py b/tests/test_random_bias_fieldd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_random_bias_fieldd.py
@@ -0,0 +1,65 @@
+# 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 RandBiasFieldd
+
+TEST_CASES_2D = [{}, (3, 32, 32)]
+TEST_CASES_3D = [{}, (3, 32, 32, 32)]
+TEST_CASES_2D_ZERO_RANGE = [{"coeff_range": (0.0, 0.0)}, (3, 32, 32)]
+TEST_CASES_2D_ONES = [{"coeff_range": (1.0, 1.0)}, np.asarray([[[2, -2], [2, 10]]])]
+
+
+class TestRandBiasFieldd(unittest.TestCase):
+    @parameterized.expand(
+        [
+            TEST_CASES_2D,
+            TEST_CASES_3D,
+        ]
+    )
+    def test_output_shape(self, class_args, img_shape):
+        key = "img"
+        bias_field = RandBiasFieldd(keys=[key], **class_args)
+        img = np.random.rand(*img_shape)
+        output = bias_field({key: img})
+        np.testing.assert_equal(output[key].shape, img_shape)
+        np.testing.assert_equal(output[key].dtype, bias_field.rand_bias_field.dtype)
+
+    @parameterized.expand([TEST_CASES_2D_ZERO_RANGE])
+    def test_zero_range(self, class_args, img_shape):
+        key = "img"
+        bias_field = RandBiasFieldd(keys=[key], **class_args)
+        img = np.random.rand(*img_shape)
+        output = bias_field({key: img})
+        np.testing.assert_equal(output[key], np.zeros(img_shape))
+
+    @parameterized.expand([TEST_CASES_2D_ONES])
+    def test_one_range_input(self, class_args, expected):
+        key = "img"
+        bias_field = RandBiasFieldd(keys=[key], **class_args)
+        img = np.ones([1, 2, 2])
+        output = bias_field({key: img})
+        np.testing.assert_equal(output[key], expected.astype(bias_field.rand_bias_field.dtype))
+
+    def test_zero_prob(self):
+        key = "img"
+        bias_field = RandBiasFieldd(keys=[key], prob=0.0)
+        img = np.random.rand(3, 32, 32)
+        output = bias_field({key: img})
+        np.testing.assert_equal(output[key], img)
+
+
+if __name__ == "__main__":
+    unittest.main()