Repository: Project-MONAI/MONAI

Issue:
Add `RandScaleCrop` transform and inverse operation
**Is your feature request related to a problem? Please describe.**
It would be useful to randomly crop images with specified scale arg as min & max size, for example: (0.3, 0.6). Then images with different sizes will use different crop size range.



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
@@ -18,6 +18,7 @@
     CropForeground,
     DivisiblePad,
     RandCropByPosNegLabel,
+    RandScaleCrop,
     RandSpatialCrop,
     RandSpatialCropSamples,
     RandWeightedCrop,
@@ -46,6 +47,9 @@
     RandCropByPosNegLabeld,
     RandCropByPosNegLabelD,
     RandCropByPosNegLabelDict,
+    RandScaleCropd,
+    RandScaleCropD,
+    RandScaleCropDict,
     RandSpatialCropd,
     RandSpatialCropD,
     RandSpatialCropDict,
diff --git a/monai/transforms/croppad/array.py b/monai/transforms/croppad/array.py
--- a/monai/transforms/croppad/array.py
+++ b/monai/transforms/croppad/array.py
@@ -14,6 +14,7 @@
 """
 
 from itertools import chain
+from math import ceil
 from typing import Any, Callable, List, Optional, Sequence, Tuple, Union
 
 import numpy as np
@@ -30,7 +31,7 @@
     map_binary_to_indices,
     weighted_patch_samples,
 )
-from monai.utils import Method, NumpyPadMode, ensure_tuple, fall_back_tuple
+from monai.utils import Method, NumpyPadMode, ensure_tuple, ensure_tuple_rep, fall_back_tuple
 
 __all__ = [
     "SpatialPad",
@@ -39,6 +40,7 @@
     "SpatialCrop",
     "CenterSpatialCrop",
     "RandSpatialCrop",
+    "RandScaleCrop",
     "RandSpatialCropSamples",
     "CropForeground",
     "RandWeightedCrop",
@@ -292,7 +294,7 @@ def __call__(self, img: np.ndarray):
 class RandSpatialCrop(Randomizable):
     """
     Crop image with random size or specific size ROI. It can crop at a random position as center
-    or at the image center. And allows to set the minimum size to limit the randomly generated ROI.
+    or at the image center. And allows to set the minimum and maximum size to limit the randomly generated ROI.
 
     Args:
         roi_size: if `random_size` is True, it specifies the minimum crop region.
@@ -303,7 +305,7 @@ class RandSpatialCrop(Randomizable):
             if its components have non-positive values, the corresponding size of input image will be used.
         random_center: crop at random position as center or the image center.
         random_size: crop with random size or specific size ROI.
-            The actual size is sampled from `randint(roi_size, img_size)`.
+            if True, the actual size is sampled from `randint(roi_size, max_roi_size + 1)`.
     """
 
     def __init__(
@@ -345,6 +347,53 @@ def __call__(self, img: np.ndarray):
         return cropper(img)
 
 
+class RandScaleCrop(RandSpatialCrop):
+    """
+    Subclass of :py:class:`monai.transforms.RandSpatialCrop`. Crop image with random size or specific size ROI.
+    It can crop at a random position as center or at the image center.
+    And allows to set the minimum and maximum scale of image size to limit the randomly generated ROI.
+
+    Args:
+        roi_scale: if `random_size` is True, it specifies the minimum crop size: `roi_scale * image spatial size`.
+            if `random_size` is False, it specifies the expected scale of image size to crop. e.g. [0.3, 0.4, 0.5].
+            If its components have non-positive values or very small scale value causes the crop size to be zero,
+            will use `1.0` instead, which means the input image size.
+        max_roi_size: if `random_size` is True and `roi_scale` specifies the min crop region size, `max_roi_scale`
+            can specify the max crop region size: `max_roi_scale * image spatial size`.
+            if None, defaults to the input image size. if its components have non-positive values,
+            will use `1.0` instead, which means the input image size.
+        random_center: crop at random position as center or the image center.
+        random_size: crop with random size or specified size ROI by `roi_scale * image spatial size`.
+            if True, the actual size is sampled from:
+            `randint(roi_scale * image spatial size, max_roi_scale * image spatial size + 1)`.
+    """
+
+    def __init__(
+        self,
+        roi_scale: Union[Sequence[float], float],
+        max_roi_scale: Optional[Union[Sequence[float], float]] = None,
+        random_center: bool = True,
+        random_size: bool = True,
+    ) -> None:
+        super().__init__(roi_size=-1, max_roi_size=None, random_center=random_center, random_size=random_size)
+        self.roi_scale = roi_scale
+        self.max_roi_scale = max_roi_scale
+
+    def __call__(self, img: np.ndarray):
+        """
+        Apply the transform to `img`, assuming `img` is channel-first and
+        slicing doesn't apply to the channel dim.
+        """
+        img_size = img.shape[1:]
+        ndim = len(img_size)
+        self.roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)]
+        if self.max_roi_scale is not None:
+            self.max_roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.max_roi_scale, ndim), img_size)]
+        else:
+            self.max_roi_size = None
+        return super().__call__(img=img)
+
+
 class RandSpatialCropSamples(Randomizable):
     """
     Crop image with random size or specific size ROI to generate a list of N samples.
diff --git a/monai/transforms/croppad/dictionary.py b/monai/transforms/croppad/dictionary.py
--- a/monai/transforms/croppad/dictionary.py
+++ b/monai/transforms/croppad/dictionary.py
@@ -18,7 +18,7 @@
 from copy import deepcopy
 from enum import Enum
 from itertools import chain
-from math import floor
+from math import ceil, floor
 from typing import Any, Callable, Dict, Hashable, List, Mapping, Optional, Sequence, Tuple, Union
 
 import numpy as np
@@ -54,6 +54,7 @@
     "DivisiblePadd",
     "SpatialCropd",
     "CenterSpatialCropd",
+    "RandScaleCropd",
     "RandSpatialCropd",
     "RandSpatialCropSamplesd",
     "CropForegroundd",
@@ -71,6 +72,8 @@
     "SpatialCropDict",
     "CenterSpatialCropD",
     "CenterSpatialCropDict",
+    "RandScaleCropD",
+    "RandScaleCropDict",
     "RandSpatialCropD",
     "RandSpatialCropDict",
     "RandSpatialCropSamplesD",
@@ -401,7 +404,7 @@ class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform):
     """
     Dictionary-based version :py:class:`monai.transforms.RandSpatialCrop`.
     Crop image with random size or specific size ROI. It can crop at a random position as
-    center or at the image center. And allows to set the minimum size to limit the randomly
+    center or at the image center. And allows to set the minimum and maximum size to limit the randomly
     generated ROI. Suppose all the expected fields specified by `keys` have same shape.
 
     Args:
@@ -415,7 +418,8 @@ class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform):
             if its components have non-positive values, the corresponding size of input image will be used.
         random_center: crop at random position as center or the image center.
         random_size: crop with random size or specific size ROI.
-            The actual size is sampled from `randint(roi_size, img_size)`.
+            if True, the actual size is sampled from:
+            `randint(roi_scale * image spatial size, max_roi_scale * image spatial size + 1)`.
         allow_missing_keys: don't raise exception if key is missing.
     """
 
@@ -495,6 +499,64 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar
         return d
 
 
+class RandScaleCropd(RandSpatialCropd):
+    """
+    Dictionary-based version :py:class:`monai.transforms.RandScaleCrop`.
+    Crop image with random size or specific size ROI.
+    It can crop at a random position as center or at the image center.
+    And allows to set the minimum and maximum scale of image size to limit the randomly generated ROI.
+    Suppose all the expected fields specified by `keys` have same shape.
+
+    Args:
+        keys: keys of the corresponding items to be transformed.
+            See also: monai.transforms.MapTransform
+        roi_scale: if `random_size` is True, it specifies the minimum crop size: `roi_scale * image spatial size`.
+            if `random_size` is False, it specifies the expected scale of image size to crop. e.g. [0.3, 0.4, 0.5].
+            If its components have non-positive values or very small scale value causes the crop size to be zero,
+            will use `1.0` instead, which means the input image size.
+        max_roi_size: if `random_size` is True and `roi_scale` specifies the min crop region size, `max_roi_scale`
+            can specify the max crop region size: `max_roi_scale * image spatial size`.
+            if None, defaults to the input image size. if its components have non-positive values,
+            will use `1.0` instead, which means the input image size.
+        random_center: crop at random position as center or the image center.
+        random_size: crop with random size or specified size ROI by `roi_scale * image spatial size`.
+            if True, the actual size is sampled from:
+            `randint(roi_scale * image spatial size, max_roi_scale * image spatial size + 1)`.
+        allow_missing_keys: don't raise exception if key is missing.
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        roi_scale: Union[Sequence[float], float],
+        max_roi_scale: Optional[Union[Sequence[float], float]] = None,
+        random_center: bool = True,
+        random_size: bool = True,
+        allow_missing_keys: bool = False,
+    ) -> None:
+        super().__init__(
+            keys=keys,
+            roi_size=-1,
+            max_roi_size=None,
+            random_center=random_center,
+            random_size=random_size,
+            allow_missing_keys=allow_missing_keys,
+        )
+        MapTransform.__init__(self, keys, allow_missing_keys)
+        self.roi_scale = roi_scale
+        self.max_roi_scale = max_roi_scale
+
+    def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
+        img_size = data[self.keys[0]].shape[1:]
+        ndim = len(img_size)
+        self.roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)]
+        if self.max_roi_scale is not None:
+            self.max_roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.max_roi_scale, ndim), img_size)]
+        else:
+            self.max_roi_size = None
+        return super().__call__(data=data)
+
+
 class RandSpatialCropSamplesd(Randomizable, MapTransform):
     """
     Dictionary-based version :py:class:`monai.transforms.RandSpatialCropSamples`.
@@ -993,6 +1055,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
 SpatialCropD = SpatialCropDict = SpatialCropd
 CenterSpatialCropD = CenterSpatialCropDict = CenterSpatialCropd
 RandSpatialCropD = RandSpatialCropDict = RandSpatialCropd
+RandScaleCropD = RandScaleCropDict = RandScaleCropd
 RandSpatialCropSamplesD = RandSpatialCropSamplesDict = RandSpatialCropSamplesd
 CropForegroundD = CropForegroundDict = CropForegroundd
 RandWeightedCropD = RandWeightedCropDict = RandWeightedCropd


Test patch:
diff --git a/tests/test_rand_scale_crop.py b/tests/test_rand_scale_crop.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_scale_crop.py
@@ -0,0 +1,77 @@
+# 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 RandScaleCrop
+
+TEST_CASE_1 = [
+    {"roi_scale": [1.0, 1.0, -1.0], "random_center": True},
+    np.random.randint(0, 2, size=[3, 3, 3, 4]),
+    (3, 3, 3, 4),
+]
+
+TEST_CASE_2 = [
+    {"roi_scale": [1.0, 1.0, 1.0], "random_center": False},
+    np.random.randint(0, 2, size=[3, 3, 3, 3]),
+    (3, 3, 3, 3),
+]
+
+TEST_CASE_3 = [
+    {"roi_scale": [0.6, 0.6], "random_center": False},
+    np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]]),
+]
+
+TEST_CASE_4 = [
+    {"roi_scale": [0.75, 0.6, 0.5], "max_roi_scale": [1.0, -1.0, 0.6], "random_center": True, "random_size": True},
+    np.random.randint(0, 2, size=[1, 4, 5, 6]),
+    (1, 3, 4, 3),
+]
+
+TEST_CASE_5 = [
+    {"roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True},
+    np.random.randint(0, 2, size=[1, 4, 5, 6]),
+    (1, 3, 4, 4),
+]
+
+TEST_CASE_6 = [
+    {"roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True},
+    np.random.randint(0, 2, size=[1, 4, 5, 6]),
+    (1, 3, 2, 4),
+]
+
+
+class TestRandScaleCrop(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+    def test_shape(self, input_param, input_data, expected_shape):
+        result = RandScaleCrop(**input_param)(input_data)
+        self.assertTupleEqual(result.shape, expected_shape)
+
+    @parameterized.expand([TEST_CASE_3])
+    def test_value(self, input_param, input_data):
+        cropper = RandScaleCrop(**input_param)
+        result = cropper(input_data)
+        roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size]
+        np.testing.assert_allclose(result, input_data[:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]])
+
+    @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
+    def test_random_shape(self, input_param, input_data, expected_shape):
+        cropper = RandScaleCrop(**input_param)
+        cropper.set_random_state(seed=123)
+        result = cropper(input_data)
+        self.assertTupleEqual(result.shape, expected_shape)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_rand_scale_cropd.py b/tests/test_rand_scale_cropd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rand_scale_cropd.py
@@ -0,0 +1,83 @@
+# 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 RandScaleCropd
+
+TEST_CASE_1 = [
+    {"keys": "img", "roi_scale": [1.0, 1.0, -1.0], "random_center": True},
+    {"img": np.random.randint(0, 2, size=[3, 3, 3, 4])},
+    (3, 3, 3, 4),
+]
+
+TEST_CASE_2 = [
+    {"keys": "img", "roi_scale": [1.0, 1.0, 1.0], "random_center": False},
+    {"img": np.random.randint(0, 2, size=[3, 3, 3, 3])},
+    (3, 3, 3, 3),
+]
+
+TEST_CASE_3 = [
+    {"keys": "img", "roi_scale": [0.6, 0.6], "random_center": False},
+    {"img": np.array([[[0, 0, 0, 0, 0], [0, 1, 2, 1, 0], [0, 2, 3, 2, 0], [0, 1, 2, 1, 0], [0, 0, 0, 0, 0]]])},
+]
+
+TEST_CASE_4 = [
+    {
+        "keys": "img",
+        "roi_scale": [0.75, 0.6, 0.5],
+        "max_roi_scale": [1.0, -1.0, 0.6],
+        "random_center": True,
+        "random_size": True,
+    },
+    {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])},
+    (1, 3, 4, 3),
+]
+
+TEST_CASE_5 = [
+    {"keys": "img", "roi_scale": 0.6, "max_roi_scale": 0.8, "random_center": True, "random_size": True},
+    {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])},
+    (1, 3, 4, 4),
+]
+
+TEST_CASE_6 = [
+    {"keys": "img", "roi_scale": 0.2, "max_roi_scale": 0.8, "random_center": True, "random_size": True},
+    {"img": np.random.randint(0, 2, size=[1, 4, 5, 6])},
+    (1, 3, 2, 4),
+]
+
+
+class TestRandScaleCropd(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2])
+    def test_shape(self, input_param, input_data, expected_shape):
+        result = RandScaleCropd(**input_param)(input_data)
+        self.assertTupleEqual(result["img"].shape, expected_shape)
+
+    @parameterized.expand([TEST_CASE_3])
+    def test_value(self, input_param, input_data):
+        cropper = RandScaleCropd(**input_param)
+        result = cropper(input_data)
+        roi = [(2 - i // 2, 2 + i - i // 2) for i in cropper._size]
+        np.testing.assert_allclose(result["img"], input_data["img"][:, roi[0][0] : roi[0][1], roi[1][0] : roi[1][1]])
+
+    @parameterized.expand([TEST_CASE_4, TEST_CASE_5, TEST_CASE_6])
+    def test_random_shape(self, input_param, input_data, expected_shape):
+        cropper = RandScaleCropd(**input_param)
+        cropper.set_random_state(seed=123)
+        result = cropper(input_data)
+        self.assertTupleEqual(result["img"].shape, expected_shape)
+
+
+if __name__ == "__main__":
+    unittest.main()