Repository: Project-MONAI/MONAI

Issue:
Finalize the design pattern for Transform.



Discussion:

Resolving patch:
diff --git a/monai/transforms/composables.py b/monai/transforms/composables.py
new file mode 100644
--- /dev/null
+++ b/monai/transforms/composables.py
@@ -0,0 +1,136 @@
+# 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.
+"""
+A collection of dictionary-based wrappers around the "vanilla" transforms
+defined in `monai.transforms.transforms`.
+"""
+
+from collections.abc import Hashable
+
+import monai
+from monai.transforms.compose import Randomizable, Transform
+from monai.transforms.transforms import Rotate90
+from monai.utils.misc import ensure_tuple
+
+export = monai.utils.export("monai.transforms")
+
+
+class MapTransform(Transform):
+    """
+    A subclass of ``monai.transforms.compose.Transform`` with an assumption
+    that the ``data`` input of ``self.__call__`` is a MutableMapping such as ``dict``.
+
+    The ``keys`` parameter will be used to get and set the actual data
+    item to transform.  That is, the callable of this transform should
+    follow the pattern:
+    ```
+        def __call__(self, data):
+            for key in self.keys:
+                if key in data:
+                    update output data with some_transform_function(data[key]).
+                else:
+                    do nothing or some exceptions handling.
+            return data
+    ```
+    """
+
+    def __init__(self, keys):
+        self.keys = ensure_tuple(keys)
+        if not self.keys:
+            raise ValueError('keys unspecified')
+        for key in self.keys:
+            if not isinstance(key, Hashable):
+                raise ValueError('keys should be a hashable or a sequence of hashables, got {}'.format(type(key)))
+
+
+@export
+class Rotate90d(MapTransform):
+    """
+    dictionary-based wrapper of Rotate90.
+    """
+
+    def __init__(self, keys, k=1, axes=(1, 2)):
+        """
+        Args:
+            k (int): number of times to rotate by 90 degrees.
+            axes (2 ints): defines the plane to rotate with 2 axes.
+        """
+        MapTransform.__init__(self, keys)
+        self.k = k
+        self.plane_axes = axes
+
+        self.rotator = Rotate90(self.k, self.plane_axes)
+
+    def __call__(self, data):
+        d = dict(data)
+        for key in self.keys:
+            d[key] = self.rotator(d[key])
+        return d
+
+
+@export
+class RandRotate90d(Randomizable, MapTransform):
+    """
+    With probability `prob`, input arrays are rotated by 90 degrees
+    in the plane specified by `axes`.
+    """
+
+    def __init__(self, keys, prob=0.1, max_k=3, axes=(1, 2)):
+        """
+        Args:
+            keys (hashable items): keys of the corresponding items to be transformed.
+                See also: monai.transform.composables.MapTransform
+            prob (float): probability of rotating.
+                (Default 0.1, with 10% probability it returns a rotated array.)
+            max_k (int): number of rotations will be sampled from `np.random.randint(max_k) + 1`.
+                (Default 3)
+            axes (2 ints): defines the plane to rotate with 2 axes.
+                (Default to (1, 2))
+        """
+        MapTransform.__init__(self, keys)
+
+        self.prob = min(max(prob, 0.0), 1.0)
+        self.max_k = max_k
+        self.axes = axes
+
+        self._do_transform = False
+        self._rand_k = 0
+
+    def randomise(self):
+        self._rand_k = self.R.randint(self.max_k) + 1
+        self._do_transform = self.R.random() < self.prob
+
+    def __call__(self, data):
+        self.randomise()
+        if not self._do_transform:
+            return data
+
+        rotator = Rotate90(self._rand_k, self.axes)
+        d = dict(data)
+        for key in self.keys:
+            d[key] = rotator(d[key])
+        return d
+
+
+# if __name__ == "__main__":
+#     import numpy as np
+#     data = {
+#         'img': np.array((1, 2, 3, 4)).reshape((1, 2, 2)),
+#         'seg': np.array((1, 2, 3, 4)).reshape((1, 2, 2)),
+#         'affine': 3,
+#         'dtype': 4,
+#         'unused': 5,
+#     }
+#     rotator = RandRotate90d(keys=['img', 'seg'], prob=0.8)
+#     # rotator.set_random_state(1234)
+#     data_result = rotator(data)
+#     print(data_result.keys())
+#     print(data_result['img'], data_result['seg'])
diff --git a/monai/transforms/compose.py b/monai/transforms/compose.py
--- a/monai/transforms/compose.py
+++ b/monai/transforms/compose.py
@@ -9,6 +9,32 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import numpy as np
+
+
+class Transform:
+    """
+    An abstract class of a ``Transform``.
+    A transform is callable that processes ``data``.
+
+    It could be stateful and may modify ``data`` in place,
+    the implementation should be aware of:
+    - thread safety when mutating its own states.
+        When used from a multi-process context, transform's instance variables are read-only.
+    - ``data`` content unused by this transform may still be used in the
+        subsequent transforms in a composed transform.
+        see also: `monai.transforms.compose.Compose`.
+    - storing too much information in ``data`` may not scale.
+    """
+
+    def __call__(self, data):
+        """
+        ``data`` is an element which often comes from an iteration over an
+        iterable, such as``torch.utils.data.Dataset``. This method should
+        return an updated version of ``data``.
+        """
+        raise NotImplementedError
+
 
 class Compose:
     """
@@ -50,6 +76,7 @@ class Compose:
     TODO: example / links to alternative approaches
 
     """
+
     def __init__(self, transforms=None):
         if transforms is None:
             transforms = []
@@ -61,3 +88,44 @@ def __call__(self, input_):
         for transform in self.transforms:
             input_ = transform(input_)
         return input_
+
+
+class Randomizable:
+    """
+    An interface for handling local numpy random state.
+    this is mainly for randomized data augmentation transforms.
+    """
+    R = np.random.RandomState()
+
+    def set_random_state(self, seed=None, state=None):
+        """
+        Set the random state locally, to control the randomness, the derived
+        classes should use `self.R` instead of `np.random` to introduce random
+        factors.
+
+        Args:
+            seed (int): set the random state with an integer seed.
+            state (np.random.RandomState): set the random state with a `np.random.RandomState` object.
+
+        Note:
+            thread safety
+        """
+        if seed is not None:
+            _seed = id(seed) if not isinstance(seed, int) else seed
+            self.R = np.random.RandomState(_seed)
+            return
+
+        if state is not None:
+            if not isinstance(state, np.random.RandomState):
+                raise ValueError('`state` must be a `np.random.RandomState`, got {}'.format(type(state)))
+            self.R = state
+            return
+
+        self.R = np.random.RandomState()
+        return
+
+    def randomise(self):
+        """
+        all self.R calls happen here so that we have a better chance to identify errors of sync the random state.
+        """
+        raise NotImplementedError
diff --git a/monai/transforms/transforms.py b/monai/transforms/transforms.py
--- a/monai/transforms/transforms.py
+++ b/monai/transforms/transforms.py
@@ -8,14 +8,18 @@
 # 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.
+"""
+A collection of "vanilla" transforms
+https://github.com/Project-MONAI/MONAI/wiki/MONAI_Design
+"""
 
-
-import torch
 import numpy as np
+import torch
 
 import monai
-from monai.transforms.utils import rescale_array
 from monai.data.utils import get_random_patch, get_valid_patch_size
+from monai.transforms.compose import Randomizable
+from monai.transforms.utils import rescale_array
 
 export = monai.utils.export("monai.transforms")
 
@@ -145,3 +149,67 @@ def __call__(self, img):
         all_pad_width = [(0, 0), (0, 0)] + data_pad_width
         img = np.pad(img, all_pad_width, self.mode)
         return img
+
+
+@export
+class Rotate90:
+    """
+    Rotate an array by 90 degrees in the plane specified by `axes`.
+    """
+
+    def __init__(self, k=1, axes=(1, 2)):
+        """
+        Args:
+            k (int): number of times to rotate by 90 degrees.
+            axes (2 ints): defines the plane to rotate with 2 axes.
+        """
+        self.k = k
+        self.plane_axes = axes
+
+    def __call__(self, img):
+        return np.rot90(img, self.k, self.plane_axes)
+
+
+@export
+class RandRotate90(Randomizable):
+    """
+    With probability `prob`, input arrays are rotated by 90 degrees
+    in the plane specified by `axes`.
+    """
+
+    def __init__(self, prob=0.1, max_k=3, axes=(1, 2)):
+        """
+        Args:
+            prob (float): probability of rotating.
+                (Default 0.1, with 10% probability it returns a rotated array)
+            max_k (int): number of rotations will be sampled from `np.random.randint(max_k) + 1`.
+                (Default 3)
+            axes (2 ints): defines the plane to rotate with 2 axes.
+                (Default (1, 2))
+        """
+        self.prob = min(max(prob, 0.0), 1.0)
+        self.max_k = max_k
+        self.axes = axes
+
+        self._do_transform = False
+        self._rand_k = 0
+
+    def randomise(self):
+        self._rand_k = self.R.randint(self.max_k) + 1
+        self._do_transform = self.R.random() < self.prob
+
+    def __call__(self, img):
+        self.randomise()
+        if not self._do_transform:
+            return img
+        rotator = Rotate90(self._rand_k, self.axes)
+        return rotator(img)
+
+
+# if __name__ == "__main__":
+#     img = np.array((1, 2, 3, 4)).reshape((1, 2, 2))
+#     rotator = RandRotate90(prob=0.0, max_k=3, axes=(1, 2))
+#     # rotator.set_random_state(1234)
+#     img_result = rotator(img)
+#     print(type(img))
+#     print(img_result)


Test patch:
diff --git a/tests/test_map_transform.py b/tests/test_map_transform.py
new file mode 100644
--- /dev/null
+++ b/tests/test_map_transform.py
@@ -0,0 +1,45 @@
+# 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
+
+from parameterized import parameterized
+
+from monai.transforms.composables import MapTransform
+
+TEST_CASES = [
+    ['item', ('item',)],
+    [None, (None,)],
+    [['item1', 'item2'], ('item1', 'item2')],
+]
+
+TEST_ILL_CASES = [
+    [list()],
+    [tuple()],
+    [[list()]],
+]
+
+
+class TestRandomizable(unittest.TestCase):
+
+    @parameterized.expand(TEST_CASES)
+    def test_keys(self, keys, expected):
+        transform = MapTransform(keys=keys)
+        self.assertEqual(transform.keys, expected)
+
+    @parameterized.expand(TEST_ILL_CASES)
+    def test_wrong_keys(self, keys):
+        with self.assertRaisesRegex(ValueError, ''):
+            MapTransform(keys=keys)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_rand_rotate90.py b/tests/test_rand_rotate90.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_rotate90.py
@@ -0,0 +1,52 @@
+# 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
+
+from monai.transforms.transforms import RandRotate90
+from tests.utils import NumpyImageTestCase2D
+
+
+class Rotate90Test(NumpyImageTestCase2D):
+
+    def test_default(self):
+        rotate = RandRotate90()
+        rotate.set_random_state(123)
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_k(self):
+        rotate = RandRotate90(max_k=2)
+        rotate.set_random_state(234)
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_axes(self):
+        rotate = RandRotate90(axes=(1, 2))
+        rotate.set_random_state(234)
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_prob_k_axes(self):
+        rotate = RandRotate90(prob=1.0, max_k=2, axes=(2, 3))
+        rotate.set_random_state(234)
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 1, (2, 3))
+        self.assertTrue(np.allclose(rotated, expected))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_rand_rotate90d.py b/tests/test_rand_rotate90d.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_rotate90d.py
@@ -0,0 +1,62 @@
+# 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
+
+from monai.transforms.composables import RandRotate90d
+from tests.utils import NumpyImageTestCase2D
+
+
+class Rotate90Test(NumpyImageTestCase2D):
+
+    def test_default(self):
+        key = None
+        rotate = RandRotate90d(keys=key)
+        rotate.set_random_state(123)
+        rotated = rotate({key: self.imt})
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated[key], expected))
+
+    def test_k(self):
+        key = 'test'
+        rotate = RandRotate90d(keys=key, max_k=2)
+        rotate.set_random_state(234)
+        rotated = rotate({key: self.imt})
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated[key], expected))
+
+    def test_axes(self):
+        key = ['test']
+        rotate = RandRotate90d(keys=key, axes=(1, 2))
+        rotate.set_random_state(234)
+        rotated = rotate({key[0]: self.imt})
+        expected = np.rot90(self.imt, 0, (1, 2))
+        self.assertTrue(np.allclose(rotated[key[0]], expected))
+
+    def test_prob_k_axes(self):
+        key = ('test',)
+        rotate = RandRotate90d(keys=key, prob=1.0, max_k=2, axes=(2, 3))
+        rotate.set_random_state(234)
+        rotated = rotate({key[0]: self.imt})
+        expected = np.rot90(self.imt, 1, (2, 3))
+        self.assertTrue(np.allclose(rotated[key[0]], expected))
+
+    def test_no_key(self):
+        key = 'unknown'
+        rotate = RandRotate90d(keys=key, prob=1.0, max_k=2, axes=(2, 3))
+        with self.assertRaisesRegex(KeyError, ''):
+            rotated = rotate({'test': self.imt})
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_randomizable.py b/tests/test_randomizable.py
new file mode 100644
--- /dev/null
+++ b/tests/test_randomizable.py
@@ -0,0 +1,44 @@
+# 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
+
+from monai.transforms.compose import Randomizable
+
+
+class TestRandomizable(unittest.TestCase):
+
+    def test_default(self):
+        inst = Randomizable()
+        r1 = inst.R.rand()
+        self.assertTrue(isinstance(inst.R, np.random.RandomState))
+        inst.set_random_state()
+        r2 = inst.R.rand()
+        self.assertNotAlmostEqual(r1, r2)
+
+    def test_seed(self):
+        inst = Randomizable()
+        inst.set_random_state(seed=123)
+        self.assertAlmostEqual(inst.R.rand(), 0.69646918)
+        inst.set_random_state(123)
+        self.assertAlmostEqual(inst.R.rand(), 0.69646918)
+
+    def test_state(self):
+        inst = Randomizable()
+        inst_r = np.random.RandomState(123)
+        inst.set_random_state(state=inst_r)
+        self.assertAlmostEqual(inst.R.rand(), 0.69646918)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_rotate90.py b/tests/test_rotate90.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rotate90.py
@@ -0,0 +1,48 @@
+# 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
+
+from monai.transforms.transforms import Rotate90
+from tests.utils import NumpyImageTestCase2D
+
+
+class Rotate90Test(NumpyImageTestCase2D):
+
+    def test_rotate90_default(self):
+        rotate = Rotate90()
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 1, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_k(self):
+        rotate = Rotate90(k=2)
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 2, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_axes(self):
+        rotate = Rotate90(axes=(1, 2))
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 1, (1, 2))
+        self.assertTrue(np.allclose(rotated, expected))
+
+    def test_k_axes(self):
+        rotate = Rotate90(k=2, axes=(2, 3))
+        rotated = rotate(self.imt)
+        expected = np.rot90(self.imt, 2, (2, 3))
+        self.assertTrue(np.allclose(rotated, expected))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/test_rotate90d.py b/tests/test_rotate90d.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rotate90d.py
@@ -0,0 +1,58 @@
+# 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
+
+from monai.transforms.composables import Rotate90d
+from tests.utils import NumpyImageTestCase2D
+
+
+class Rotate90Test(NumpyImageTestCase2D):
+
+    def test_rotate90_default(self):
+        key = 'test'
+        rotate = Rotate90d(keys=key)
+        rotated = rotate({key: self.imt})
+        expected = np.rot90(self.imt, 1, (1, 2))
+        self.assertTrue(np.allclose(rotated[key], expected))
+
+    def test_k(self):
+        key = None
+        rotate = Rotate90d(keys=key, k=2)
+        rotated = rotate({key: self.imt})
+        expected = np.rot90(self.imt, 2, (1, 2))
+        self.assertTrue(np.allclose(rotated[key], expected))
+
+    def test_axes(self):
+        key = ['test']
+        rotate = Rotate90d(keys=key, axes=(1, 2))
+        rotated = rotate({key[0]: self.imt})
+        expected = np.rot90(self.imt, 1, (1, 2))
+        self.assertTrue(np.allclose(rotated[key[0]], expected))
+
+    def test_k_axes(self):
+        key = ('test',)
+        rotate = Rotate90d(keys=key, k=2, axes=(2, 3))
+        rotated = rotate({key[0]: self.imt})
+        expected = np.rot90(self.imt, 2, (2, 3))
+        self.assertTrue(np.allclose(rotated[key[0]], expected))
+
+    def test_no_key(self):
+        key = 'unknown'
+        rotate = Rotate90d(keys=key)
+        with self.assertRaisesRegex(KeyError, ''):
+            rotate({'test': self.imt})
+
+
+if __name__ == '__main__':
+    unittest.main()