Repository: Project-MONAI/MONAI

Issue:
resize with pad or crop
**Is your feature request related to a problem? Please describe.**
this is a feature request about a convenient transform to:
Resize an image to a target spatial size by either centrally cropping the image or padding it evenly with a user-specified mode.
- when the dimension is smaller than the target size, do central cropping along that dim
- when the dimension is larger than the target size, do symmetric padding along that dim

**Describe the solution you'd like**
a transform class with extensive options

**Describe alternatives you've considered**
could be achieved by composing the current `SpatialPad` and `CenterSpatialCrop`
but it is a commonly used combination, would be great to have it ready




Discussion:

Resolving patch:
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
@@ -536,3 +536,42 @@ def __call__(
                 results.append(cropper(img))
 
         return results
+
+
+class ResizeWithPadOrCrop(Transform):
+    """
+    Resize an image to a target spatial size by either centrally cropping the image or
+    padding it evenly with a user-specified mode.
+    when the dimension is smaller than the target size, do central cropping along that dim.
+    when the dimension is larger than the target size, do symmetric padding along that dim.
+
+    Args:
+        spatial_size: the spatial size of output data after padding or crop.
+            If has non-positive values, the corresponding size of input image will be used (no padding).
+        mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``,
+            ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
+            One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``.
+            See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+
+    """
+
+    def __init__(
+        self,
+        spatial_size: Union[Sequence[int], int],
+        mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT,
+    ):
+        self.padder = SpatialPad(spatial_size=spatial_size, mode=mode)
+        self.cropper = CenterSpatialCrop(roi_size=spatial_size)
+
+    def __call__(self, img: np.ndarray, mode: Optional[Union[NumpyPadMode, str]] = None) -> np.ndarray:
+        """
+        Args:
+            img: data to pad or crop, assuming `img` is channel-first and
+                padding or cropping doesn't apply to the channel dim.
+            mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``,
+                ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
+                One of the listed string values or a user supplied function for padding.
+                If None, defaults to the ``mode`` in construction.
+                See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+        """
+        return self.cropper(self.padder(img, mode=mode))
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
@@ -22,7 +22,14 @@
 from monai.config import IndexSelection, KeysCollection
 from monai.data.utils import get_random_patch, get_valid_patch_size
 from monai.transforms.compose import MapTransform, Randomizable
-from monai.transforms.croppad.array import BorderPad, CenterSpatialCrop, DivisiblePad, SpatialCrop, SpatialPad
+from monai.transforms.croppad.array import (
+    BorderPad,
+    CenterSpatialCrop,
+    DivisiblePad,
+    ResizeWithPadOrCrop,
+    SpatialCrop,
+    SpatialPad,
+)
 from monai.transforms.utils import (
     generate_pos_neg_label_crop_centers,
     generate_spatial_bounding_box,
@@ -461,6 +468,38 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n
         return results
 
 
+class ResizeWithPadOrCropd(MapTransform):
+    """
+    Dictionary-based wrapper of :py:class:`monai.transforms.ResizeWithPadOrCrop`.
+
+    Args:
+        keys: keys of the corresponding items to be transformed.
+            See also: monai.transforms.MapTransform
+        spatial_size: the spatial size of output data after padding or crop.
+            If has non-positive values, the corresponding size of input image will be used (no padding).
+        mode: {``"constant"``, ``"edge"``, ``"linear_ramp"``, ``"maximum"``, ``"mean"``,
+            ``"median"``, ``"minimum"``, ``"reflect"``, ``"symmetric"``, ``"wrap"``, ``"empty"``}
+            One of the listed string values or a user supplied function for padding. Defaults to ``"constant"``.
+            See also: https://numpy.org/doc/1.18/reference/generated/numpy.pad.html
+
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        spatial_size: Union[Sequence[int], int],
+        mode: Union[NumpyPadMode, str] = NumpyPadMode.CONSTANT,
+    ) -> None:
+        super().__init__(keys)
+        self.padcropper = ResizeWithPadOrCrop(spatial_size=spatial_size, mode=mode)
+
+    def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.ndarray]:
+        d = dict(data)
+        for key in self.keys:
+            d[key] = self.padcropper(d[key])
+        return d
+
+
 SpatialPadD = SpatialPadDict = SpatialPadd
 BorderPadD = BorderPadDict = BorderPadd
 DivisiblePadD = DivisiblePadDict = DivisiblePadd
@@ -470,3 +509,4 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> List[Dict[Hashable, n
 RandSpatialCropSamplesD = RandSpatialCropSamplesDict = RandSpatialCropSamplesd
 CropForegroundD = CropForegroundDict = CropForegroundd
 RandCropByPosNegLabelD = RandCropByPosNegLabelDict = RandCropByPosNegLabeld
+ResizeWithPadOrCropD = ResizeWithPadOrCropDict = ResizeWithPadOrCropd


Test patch:
diff --git a/tests/test_resize_with_pad_or_crop.py b/tests/test_resize_with_pad_or_crop.py
new file mode 100644
--- /dev/null
+++ b/tests/test_resize_with_pad_or_crop.py
@@ -0,0 +1,55 @@
+# 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 parameterized import parameterized
+
+from monai.transforms import ResizeWithPadOrCrop
+
+TEST_CASE_1 = [
+    {"spatial_size": [15, 8, 8], "mode": "constant"},
+    np.zeros((3, 8, 8, 4)),
+    np.zeros((3, 15, 8, 8)),
+]
+
+TEST_CASE_2 = [
+    {"spatial_size": [15, 4, 8], "mode": "constant"},
+    np.zeros((3, 8, 8, 4)),
+    np.zeros((3, 15, 4, 8)),
+]
+
+TEST_CASE_3 = [
+    {"spatial_size": [15, 4, -1], "mode": "constant"},
+    np.zeros((3, 8, 8, 4)),
+    np.zeros((3, 15, 4, 4)),
+]
+
+TEST_CASE_4 = [
+    {"spatial_size": [15, 4, -1], "mode": "reflect"},
+    np.zeros((3, 8, 8, 4)),
+    np.zeros((3, 15, 4, 4)),
+]
+
+
+class TestResizeWithPadOrCrop(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
+    def test_pad_shape(self, input_param, input_data, expected_val):
+        paddcroper = ResizeWithPadOrCrop(**input_param)
+        result = paddcroper(input_data)
+        np.testing.assert_allclose(result.shape, expected_val.shape)
+        result = paddcroper(input_data, mode="constant")
+        np.testing.assert_allclose(result.shape, expected_val.shape)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_resize_with_pad_or_cropd.py b/tests/test_resize_with_pad_or_cropd.py
new file mode 100644
--- /dev/null
+++ b/tests/test_resize_with_pad_or_cropd.py
@@ -0,0 +1,53 @@
+# 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 parameterized import parameterized
+
+from monai.transforms import ResizeWithPadOrCropd
+
+TEST_CASE_1 = [
+    {"keys": "img", "spatial_size": [15, 8, 8], "mode": "constant"},
+    {"img": np.zeros((3, 8, 8, 4))},
+    np.zeros((3, 15, 8, 8)),
+]
+
+TEST_CASE_2 = [
+    {"keys": "img", "spatial_size": [15, 4, 8], "mode": "constant"},
+    {"img": np.zeros((3, 8, 8, 4))},
+    np.zeros((3, 15, 4, 8)),
+]
+
+TEST_CASE_3 = [
+    {"keys": "img", "spatial_size": [15, 4, -1], "mode": "constant"},
+    {"img": np.zeros((3, 8, 8, 4))},
+    np.zeros((3, 15, 4, 4)),
+]
+
+TEST_CASE_4 = [
+    {"keys": "img", "spatial_size": [15, 4, -1], "mode": "reflect"},
+    {"img": np.zeros((3, 8, 8, 4))},
+    np.zeros((3, 15, 4, 4)),
+]
+
+
+class TestResizeWithPadOrCropd(unittest.TestCase):
+    @parameterized.expand([TEST_CASE_1, TEST_CASE_2, TEST_CASE_3, TEST_CASE_4])
+    def test_pad_shape(self, input_param, input_data, expected_val):
+        paddcroper = ResizeWithPadOrCropd(**input_param)
+        result = paddcroper(input_data)
+        np.testing.assert_allclose(result["img"].shape, expected_val.shape)
+
+
+if __name__ == "__main__":
+    unittest.main()