Repository: Project-MONAI/MONAI

Issue:
Add `ScaleCenterCrop` transform
**Is your feature request related to a problem? Please describe.**
Need to implement a deterministic transform that center crop the image with a scale value.



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
@@ -14,6 +14,7 @@
 from .croppad.array import (
     BorderPad,
     BoundingRect,
+    CenterScaleCrop,
     CenterSpatialCrop,
     CropForeground,
     DivisiblePad,
@@ -34,6 +35,9 @@
     BoundingRectd,
     BoundingRectD,
     BoundingRectDict,
+    CenterScaleCropd,
+    CenterScaleCropD,
+    CenterScaleCropDict,
     CenterSpatialCropd,
     CenterSpatialCropD,
     CenterSpatialCropDict,
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
@@ -39,6 +39,7 @@
     "DivisiblePad",
     "SpatialCrop",
     "CenterSpatialCrop",
+    "CenterScaleCrop",
     "RandSpatialCrop",
     "RandScaleCrop",
     "RandSpatialCropSamples",
@@ -291,6 +292,28 @@ def __call__(self, img: np.ndarray):
         return cropper(img)
 
 
+class CenterScaleCrop(CenterSpatialCrop):
+    """
+    Subclass of :py:class:`monai.transforms.CenterSpatialCrop`.
+    Crop at the center of image with specified scale of ROI size.
+
+    Args:
+        roi_scale: specifies the expected scale of image size to crop. e.g. [0.3, 0.4, 0.5] or a number for all dims.
+            If its components have non-positive values, will use `1.0` instead, which means the input image size.
+
+    """
+
+    def __init__(self, roi_scale: Union[Sequence[float], float]):
+        super().__init__(roi_size=0)
+        self.roi_scale = roi_scale
+
+    def __call__(self, img: np.ndarray):
+        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)]
+        return super().__call__(img=img)
+
+
 class RandSpatialCrop(Randomizable):
     """
     Crop image with random size or specific size ROI. It can crop at a random position as center
@@ -356,8 +379,7 @@ class RandScaleCrop(RandSpatialCrop):
     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.
+            If its components have non-positive values, 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,
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
@@ -54,6 +54,7 @@
     "DivisiblePadd",
     "SpatialCropd",
     "CenterSpatialCropd",
+    "CenterScaleCropd",
     "RandScaleCropd",
     "RandSpatialCropd",
     "RandSpatialCropSamplesd",
@@ -72,6 +73,8 @@
     "SpatialCropDict",
     "CenterSpatialCropD",
     "CenterSpatialCropDict",
+    "CenterScaleCropD",
+    "CenterScaleCropDict",
     "RandScaleCropD",
     "RandScaleCropDict",
     "RandSpatialCropD",
@@ -400,6 +403,32 @@ def inverse(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndar
         return d
 
 
+class CenterScaleCropd(CenterSpatialCropd):
+    """
+    Dictionary-based wrapper of :py:class:`monai.transforms.CenterScaleCrop`.
+
+    Args:
+        keys: keys of the corresponding items to be transformed.
+            See also: monai.transforms.MapTransform
+        roi_scale: specifies the expected scale of image size to crop. e.g. [0.3, 0.4, 0.5] or a number for all dims.
+            If its components have non-positive values, will use `1.0` instead, which means the input image size.
+        allow_missing_keys: don't raise exception if key is missing.
+    """
+
+    def __init__(
+        self, keys: KeysCollection, roi_scale: Union[Sequence[float], float], allow_missing_keys: bool = False
+    ) -> None:
+        super().__init__(keys, roi_size=0, allow_missing_keys=allow_missing_keys)
+        self.roi_scale = roi_scale
+
+    def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
+        d = dict(data)
+        img_size = data[self.keys[0]].shape[1:]
+        ndim = len(img_size)
+        self.cropper.roi_size = [ceil(r * s) for r, s in zip(ensure_tuple_rep(self.roi_scale, ndim), img_size)]
+        return super().__call__(data=data)
+
+
 class RandSpatialCropd(Randomizable, MapTransform, InvertibleTransform):
     """
     Dictionary-based version :py:class:`monai.transforms.RandSpatialCrop`.
@@ -512,8 +541,7 @@ class RandScaleCropd(RandSpatialCropd):
             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.
+            If its components have non-positive values, 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,
@@ -1054,6 +1082,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
 DivisiblePadD = DivisiblePadDict = DivisiblePadd
 SpatialCropD = SpatialCropDict = SpatialCropd
 CenterSpatialCropD = CenterSpatialCropDict = CenterSpatialCropd
+CenterScaleCropD = CenterScaleCropDict = CenterScaleCropd
 RandSpatialCropD = RandSpatialCropDict = RandSpatialCropd
 RandScaleCropD = RandScaleCropDict = RandScaleCropd
 RandSpatialCropSamplesD = RandSpatialCropSamplesDict = RandSpatialCropSamplesd


Test patch:
diff --git a/tests/test_center_scale_crop.py b/tests/test_center_scale_crop.py
new file mode 100644
--- /dev/null
+++ b/tests/test_center_scale_crop.py
@@ -0,0 +1,50 @@
+# 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
+import torch
+from parameterized import parameterized
+
+from monai.transforms import CenterScaleCrop
+
+TEST_CASE_0 = [{"roi_scale": [0.6, 0.3, -1]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 1, 3)]
+
+TEST_CASE_1 = [{"roi_scale": 0.6}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 2)]
+
+TEST_CASE_2 = [
+    {"roi_scale": [0.4, 0.4]},
+    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]]]),
+    np.array([[[1, 2], [2, 3]]]),
+]
+
+TEST_CASE_3 = [
+    {"roi_scale": 0.5},
+    torch.randint(0, 2, size=[3, 3, 3, 3], device="cuda" if torch.cuda.is_available() else "cpu"),
+    (3, 2, 2, 2),
+]
+
+
+class TestCenterScaleCrop(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_3])
+    def test_shape(self, input_param, input_data, expected_shape):
+        result = CenterScaleCrop(**input_param)(input_data)
+        np.testing.assert_allclose(result.shape, expected_shape)
+
+    @parameterized.expand([TEST_CASE_2])
+    def test_value(self, input_param, input_data, expected_value):
+        result = CenterScaleCrop(**input_param)(input_data)
+        np.testing.assert_allclose(result, expected_value)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_center_scale_cropd.py b/tests/test_center_scale_cropd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_center_scale_cropd.py
@@ -0,0 +1,50 @@
+# 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
+import torch
+from parameterized import parameterized
+
+from monai.transforms import CenterScaleCropd
+
+TEST_CASE_0 = [{"keys": "img", "roi_scale": [0.6, 0.3, -1]}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 1, 3)]
+
+TEST_CASE_1 = [{"keys": "img", "roi_scale": 0.6}, np.random.randint(0, 2, size=[3, 3, 3, 3]), (3, 2, 2, 2)]
+
+TEST_CASE_2 = [
+    {"keys": "img", "roi_scale": [0.4, 0.4]},
+    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]]]),
+    np.array([[[1, 2], [2, 3]]]),
+]
+
+TEST_CASE_3 = [
+    {"keys": "img", "roi_scale": 0.5},
+    torch.randint(0, 2, size=[3, 3, 3, 3], device="cuda" if torch.cuda.is_available() else "cpu"),
+    (3, 2, 2, 2),
+]
+
+
+class TestCenterScaleCropd(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_0, TEST_CASE_1, TEST_CASE_3])
+    def test_shape(self, input_param, input_data, expected_shape):
+        result = CenterScaleCropd(**input_param)({"img": input_data})
+        np.testing.assert_allclose(result["img"].shape, expected_shape)
+
+    @parameterized.expand([TEST_CASE_2])
+    def test_value(self, input_param, input_data, expected_value):
+        result = CenterScaleCropd(**input_param)({"img": input_data})
+        np.testing.assert_allclose(result["img"], expected_value)
+
+
+if __name__ == "__main__":
+    unittest.main()