Repository: Project-MONAI/MONAI

Issue:
Affined is missing
**Is your feature request related to a problem? Please describe.**
`Affine` and `RandAffine` exist, but on the dictionary side, there is `RandAffined`, but no `Affined`.

**Describe the solution you'd like**
Add `Affined`.


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
@@ -195,6 +195,9 @@
     Zoom,
 )
 from .spatial.dictionary import (
+    Affined,
+    AffineD,
+    AffineDict,
     Flipd,
     FlipD,
     FlipDict,
diff --git a/monai/transforms/spatial/dictionary.py b/monai/transforms/spatial/dictionary.py
--- a/monai/transforms/spatial/dictionary.py
+++ b/monai/transforms/spatial/dictionary.py
@@ -24,6 +24,7 @@
 from monai.networks.layers.simplelayers import GaussianFilter
 from monai.transforms.croppad.array import CenterSpatialCrop
 from monai.transforms.spatial.array import (
+    Affine,
     Flip,
     Orientation,
     Rand2DElastic,
@@ -53,6 +54,7 @@
     "Rotate90d",
     "RandRotate90d",
     "Resized",
+    "Affined",
     "RandAffined",
     "Rand2DElasticd",
     "Rand3DElasticd",
@@ -73,6 +75,8 @@
     "RandRotate90Dict",
     "ResizeD",
     "ResizeDict",
+    "AffineD",
+    "AffineDict",
     "RandAffineD",
     "RandAffineDict",
     "Rand2DElasticD",
@@ -378,6 +382,79 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
         return d
 
 
+class Affined(RandomizableTransform, MapTransform):
+    """
+    Dictionary-based wrapper of :py:class:`monai.transforms.Affine`.
+    """
+
+    def __init__(
+        self,
+        keys: KeysCollection,
+        rotate_params: Optional[Union[Sequence[float], float]] = None,
+        shear_params: Optional[Union[Sequence[float], float]] = None,
+        translate_params: Optional[Union[Sequence[float], float]] = None,
+        scale_params: Optional[Union[Sequence[float], float]] = None,
+        spatial_size: Optional[Union[Sequence[int], int]] = None,
+        mode: GridSampleModeSequence = GridSampleMode.BILINEAR,
+        padding_mode: GridSamplePadModeSequence = GridSamplePadMode.REFLECTION,
+        as_tensor_output: bool = False,
+        device: Optional[torch.device] = None,
+        allow_missing_keys: bool = False,
+    ) -> None:
+        """
+        Args:
+            keys: keys of the corresponding items to be transformed.
+            rotate_params: a rotation angle in radians, a scalar for 2D image, a tuple of 3 floats for 3D.
+                Defaults to no rotation.
+            shear_params: a tuple of 2 floats for 2D, a tuple of 6 floats for 3D. Defaults to no shearing.
+            translate_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Translation is in
+                pixel/voxel relative to the center of the input image. Defaults to no translation.
+            scale_params: a tuple of 2 floats for 2D, a tuple of 3 floats for 3D. Defaults to no scaling.
+            spatial_size: output image spatial size.
+                if `spatial_size` and `self.spatial_size` are not defined, or smaller than 1,
+                the transform will use the spatial size of `img`.
+                if the components of the `spatial_size` are non-positive values, the transform will use the
+                corresponding components of img size. For example, `spatial_size=(32, -1)` will be adapted
+                to `(32, 64)` if the second spatial dimension size of img is `64`.
+            mode: {``"bilinear"``, ``"nearest"``}
+                Interpolation mode to calculate output values. Defaults to ``"bilinear"``.
+                See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
+                It also can be a sequence of string, each element corresponds to a key in ``keys``.
+            padding_mode: {``"zeros"``, ``"border"``, ``"reflection"``}
+                Padding mode for outside grid values. Defaults to ``"reflection"``.
+                See also: https://pytorch.org/docs/stable/nn.functional.html#grid-sample
+                It also can be a sequence of string, each element corresponds to a key in ``keys``.
+            as_tensor_output: the computation is implemented using pytorch tensors, this option specifies
+                whether to convert it back to numpy arrays.
+            device: device on which the tensor will be allocated.
+            allow_missing_keys: don't raise exception if key is missing.
+
+        See also:
+            - :py:class:`monai.transforms.compose.MapTransform`
+            - :py:class:`RandAffineGrid` for the random affine parameters configurations.
+        """
+        MapTransform.__init__(self, keys, allow_missing_keys)
+        self.affine = Affine(
+            rotate_params=rotate_params,
+            shear_params=shear_params,
+            translate_params=translate_params,
+            scale_params=scale_params,
+            spatial_size=spatial_size,
+            as_tensor_output=as_tensor_output,
+            device=device,
+        )
+        self.mode = ensure_tuple_rep(mode, len(self.keys))
+        self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys))
+
+    def __call__(
+        self, data: Mapping[Hashable, Union[np.ndarray, torch.Tensor]]
+    ) -> Dict[Hashable, Union[np.ndarray, torch.Tensor]]:
+        d = dict(data)
+        for key, mode, padding_mode in self.key_iterator(d, self.mode, self.padding_mode):
+            d[key] = self.affine(d[key], mode=mode, padding_mode=padding_mode)
+        return d
+
+
 class RandAffined(RandomizableTransform, MapTransform):
     """
     Dictionary-based wrapper of :py:class:`monai.transforms.RandAffine`.
@@ -1122,6 +1199,7 @@ def __call__(self, data: Mapping[Hashable, np.ndarray]) -> Dict[Hashable, np.nda
 Rotate90D = Rotate90Dict = Rotate90d
 RandRotate90D = RandRotate90Dict = RandRotate90d
 ResizeD = ResizeDict = Resized
+AffineD = AffineDict = Affined
 RandAffineD = RandAffineDict = RandAffined
 Rand2DElasticD = Rand2DElasticDict = Rand2DElasticd
 Rand3DElasticD = Rand3DElasticDict = Rand3DElasticd


Test patch:
diff --git a/tests/test_affined.py b/tests/test_affined.py
new file mode 100644
--- /dev/null
+++ b/tests/test_affined.py
@@ -0,0 +1,104 @@
+# 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 Affined
+
+TEST_CASES = [
+    [
+        dict(keys="img", padding_mode="zeros", as_tensor_output=False, spatial_size=(-1, 0), device=None),
+        {"img": np.arange(9).reshape((1, 3, 3))},
+        np.arange(9).reshape(1, 3, 3),
+    ],
+    [
+        dict(keys="img", padding_mode="zeros", as_tensor_output=False, device=None),
+        {"img": np.arange(4).reshape((1, 2, 2))},
+        np.arange(4).reshape(1, 2, 2),
+    ],
+    [
+        dict(keys="img", padding_mode="zeros", spatial_size=(4, 4), as_tensor_output=False, device=None),
+        {"img": np.arange(4).reshape((1, 2, 2))},
+        np.array([[[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 2.0, 3.0, 0.0], [0.0, 0.0, 0.0, 0.0]]]),
+    ],
+    [
+        dict(
+            keys="img",
+            rotate_params=[np.pi / 2],
+            padding_mode="zeros",
+            spatial_size=(4, 4),
+            as_tensor_output=False,
+            device=None,
+        ),
+        {"img": np.arange(4).reshape((1, 2, 2))},
+        np.array([[[0.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 3.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]]]),
+    ],
+    [
+        dict(keys="img", padding_mode="zeros", spatial_size=(-1, 0, 0), as_tensor_output=False, device=None),
+        {"img": np.arange(27).reshape((1, 3, 3, 3))},
+        np.arange(27).reshape(1, 3, 3, 3),
+    ],
+    [
+        dict(keys="img", padding_mode="zeros", spatial_size=(4, 4, 4), as_tensor_output=False, device=None),
+        {"img": np.arange(8).reshape((1, 2, 2, 2))},
+        np.array(
+            [
+                [
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 2.0, 3.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 4.0, 5.0, 0.0], [0.0, 6.0, 7.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                ]
+            ]
+        ),
+    ],
+    [
+        dict(
+            keys="img",
+            rotate_params=[np.pi / 2],
+            padding_mode="zeros",
+            spatial_size=(4, 4, 4),
+            as_tensor_output=False,
+            device=None,
+        ),
+        {"img": np.arange(8).reshape((1, 2, 2, 2))},
+        np.array(
+            [
+                [
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 3.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 6.0, 4.0, 0.0], [0.0, 7.0, 5.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                    [[0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0]],
+                ]
+            ]
+        ),
+    ],
+]
+
+
+class TestAffined(unittest.TestCase):
+    @parameterized.expand(TEST_CASES)
+    def test_affine(self, input_param, input_data, expected_val):
+        g = Affined(**input_param)
+        result = g(input_data)["img"]
+        self.assertEqual(isinstance(result, torch.Tensor), isinstance(expected_val, torch.Tensor))
+        if isinstance(result, torch.Tensor):
+            np.testing.assert_allclose(result.cpu().numpy(), expected_val.cpu().numpy(), rtol=1e-4, atol=1e-4)
+        else:
+            np.testing.assert_allclose(result, expected_val, rtol=1e-4, atol=1e-4)
+
+
+if __name__ == "__main__":
+    unittest.main()