Repository: Project-MONAI/MONAI

Issue:
Unify the rotate angle in transforms
**Is your feature request related to a problem? Please describe.**
Currently, `Rotate`, `RandRotate`, etc. use the degree to set rotate angle, but `RandAffine`, `Rand3DElastic`, etc. use radian, need to unify them for better user experience.



Discussion:

Resolving patch:
diff --git a/monai/transforms/spatial/array.py b/monai/transforms/spatial/array.py
--- a/monai/transforms/spatial/array.py
+++ b/monai/transforms/spatial/array.py
@@ -358,7 +358,7 @@ class Rotate(Transform):
     Rotates an input image by given angle using :py:class:`monai.networks.layers.AffineTransform`.
 
     Args:
-        angle: Rotation angle(s) in degrees. should a float for 2D, three floats for 3D.
+        angle: Rotation angle(s) in radians. should a float for 2D, three floats for 3D.
         keep_size: If it is True, the output shape is kept the same as the input.
             If it is False, the output shape is adapted so that the
             input array is contained completely in the output. Default is True.
@@ -426,8 +426,7 @@ def __call__(
         if input_ndim not in (2, 3):
             raise ValueError(f"Unsupported img dimension: {input_ndim}, available options are [2, 3].")
         _angle = ensure_tuple_rep(self.angle, 1 if input_ndim == 2 else 3)
-        _rad = np.deg2rad(_angle)
-        transform = create_rotate(input_ndim, _rad)
+        transform = create_rotate(input_ndim, _angle)
         shift = create_translate(input_ndim, (im_shape - 1) / 2)
         if self.keep_size:
             output_shape = im_shape
@@ -613,11 +612,11 @@ class RandRotate(Randomizable, Transform):
     Randomly rotate the input arrays.
 
     Args:
-        range_x: Range of rotation angle in degrees in the plane defined by the first and second axes.
+        range_x: Range of rotation angle in radians in the plane defined by the first and second axes.
             If single number, angle is uniformly sampled from (-range_x, range_x).
-        range_y: Range of rotation angle in degrees in the plane defined by the first and third axes.
+        range_y: Range of rotation angle in radians in the plane defined by the first and third axes.
             If single number, angle is uniformly sampled from (-range_y, range_y).
-        range_z: Range of rotation angle in degrees in the plane defined by the second and third axes.
+        range_z: Range of rotation angle in radians in the plane defined by the second and third axes.
             If single number, angle is uniformly sampled from (-range_z, range_z).
         prob: Probability of rotation.
         keep_size: If it is False, the output shape is adapted so that the
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
@@ -722,7 +722,7 @@ class Rotated(MapTransform):
 
     Args:
         keys: Keys to pick data for transformation.
-        angle: Rotation angle(s) in degrees.
+        angle: Rotation angle(s) in radians.
         keep_size: If it is False, the output shape is adapted so that the
             input array is contained completely in the output.
             If it is True, the output shape is the same as the input. Default is True.
@@ -781,11 +781,11 @@ class RandRotated(Randomizable, MapTransform):
 
     Args:
         keys: Keys to pick data for transformation.
-        range_x: Range of rotation angle in degrees in the plane defined by the first and second axes.
+        range_x: Range of rotation angle in radians in the plane defined by the first and second axes.
             If single number, angle is uniformly sampled from (-range_x, range_x).
-        range_y: Range of rotation angle in degrees in the plane defined by the first and third axes.
+        range_y: Range of rotation angle in radians in the plane defined by the first and third axes.
             If single number, angle is uniformly sampled from (-range_y, range_y).
-        range_z: Range of rotation angle in degrees in the plane defined by the second and third axes.
+        range_z: Range of rotation angle in radians in the plane defined by the second and third axes.
             If single number, angle is uniformly sampled from (-range_z, range_z).
         prob: Probability of rotation.
         keep_size: If it is False, the output shape is adapted so that the


Test patch:
diff --git a/tests/test_integration_classification_2d.py b/tests/test_integration_classification_2d.py
--- a/tests/test_integration_classification_2d.py
+++ b/tests/test_integration_classification_2d.py
@@ -52,7 +52,7 @@ def run_training_test(root_dir, train_x, train_y, val_x, val_y, device="cuda:0")
             LoadPNG(image_only=True),
             AddChannel(),
             ScaleIntensity(),
-            RandRotate(range_x=15, prob=0.5, keep_size=True),
+            RandRotate(range_x=np.pi / 12, prob=0.5, keep_size=True),
             RandFlip(spatial_axis=0, prob=0.5),
             RandZoom(min_zoom=0.9, max_zoom=1.1, prob=0.5),
             ToTensor(),
diff --git a/tests/test_rand_rotate.py b/tests/test_rand_rotate.py
--- a/tests/test_rand_rotate.py
+++ b/tests/test_rand_rotate.py
@@ -22,10 +22,10 @@
 class TestRandRotate2D(NumpyImageTestCase2D):
     @parameterized.expand(
         [
-            (90, True, "bilinear", "border", False),
-            (45, True, "nearest", "border", False),
-            (180, False, "nearest", "zeros", True),
-            ((-45, 0), False, "nearest", "zeros", True),
+            (np.pi / 2, True, "bilinear", "border", False),
+            (np.pi / 4, True, "nearest", "border", False),
+            (np.pi, False, "nearest", "zeros", True),
+            ((-np.pi / 4, 0), False, "nearest", "zeros", True),
         ]
     )
     def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_corners):
@@ -49,7 +49,7 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor
             _mode = "constant"
         angle = rotate_fn.x
         expected = scipy.ndimage.rotate(
-            self.imt[0, 0], -angle, (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
+            self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
         )
         expected = np.stack(expected).astype(np.float32)
         np.testing.assert_allclose(expected, rotated[0])
@@ -58,10 +58,28 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor
 class TestRandRotate3D(NumpyImageTestCase3D):
     @parameterized.expand(
         [
-            (90, -30, (0.0, 180), False, "bilinear", "border", False, (1, 87, 104, 109)),
-            (45, (-20, 40), (20, 30), False, "nearest", "border", True, (1, 89, 105, 104)),
-            (0.0, (360, 370), (-1, 1), True, "nearest", "zeros", True, (1, 48, 64, 80)),
-            ((-45, 0), 0, 0, False, "nearest", "zeros", False, (1, 48, 77, 90)),
+            (np.pi / 2, -np.pi / 6, (0.0, np.pi), False, "bilinear", "border", False, (1, 87, 104, 109)),
+            (
+                np.pi / 4,
+                (-np.pi / 9, np.pi / 4.5),
+                (np.pi / 9, np.pi / 6),
+                False,
+                "nearest",
+                "border",
+                True,
+                (1, 89, 105, 104),
+            ),
+            (
+                0.0,
+                (2 * np.pi, 2.06 * np.pi),
+                (-np.pi / 180, np.pi / 180),
+                True,
+                "nearest",
+                "zeros",
+                True,
+                (1, 48, 64, 80),
+            ),
+            ((-np.pi / 4, 0), 0, 0, False, "nearest", "zeros", False, (1, 48, 77, 90)),
         ]
     )
     def test_correct_results(self, x, y, z, keep_size, mode, padding_mode, align_corners, expected):
diff --git a/tests/test_rand_rotated.py b/tests/test_rand_rotated.py
--- a/tests/test_rand_rotated.py
+++ b/tests/test_rand_rotated.py
@@ -23,10 +23,10 @@
 class TestRandRotated2D(NumpyImageTestCase2D):
     @parameterized.expand(
         [
-            (90, True, "bilinear", "border", False),
-            (45, True, "nearest", "border", False),
-            (180, False, "nearest", "zeros", True),
-            ((-45, 0), False, "nearest", "zeros", True),
+            (np.pi / 2, True, "bilinear", "border", False),
+            (np.pi / 4, True, "nearest", "border", False),
+            (np.pi, False, "nearest", "zeros", True),
+            ((-np.pi / 4, 0), False, "nearest", "zeros", True),
         ]
     )
     def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_corners):
@@ -51,7 +51,7 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor
             _mode = "constant"
         angle = rotate_fn.x
         expected = scipy.ndimage.rotate(
-            self.imt[0, 0], -angle, (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
+            self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
         )
         expected = np.stack(expected).astype(np.float32)
         self.assertTrue(np.allclose(expected, rotated["img"][0]))
@@ -60,14 +60,59 @@ def test_correct_results(self, degrees, keep_size, mode, padding_mode, align_cor
 class TestRandRotated3D(NumpyImageTestCase3D):
     @parameterized.expand(
         [
-            (90, -30, (0.0, 180), False, "bilinear", "border", False, (1, 87, 104, 109)),
-            (90, -30, (0.0, 180), False, GridSampleMode.NEAREST, GridSamplePadMode.BORDER, False, (1, 87, 104, 109)),
-            (45, (-20, 40), (20, 30), False, "nearest", "border", True, (1, 89, 105, 104)),
-            (45, (-20, 40), (20, 30), False, GridSampleMode.NEAREST, GridSamplePadMode.BORDER, True, (1, 89, 105, 104)),
-            (0.0, (360, 370), (-1, 1), True, "nearest", "zeros", True, (1, 48, 64, 80)),
-            (0.0, (360, 370), (-1, 1), True, GridSampleMode.NEAREST, GridSamplePadMode.ZEROS, True, (1, 48, 64, 80)),
-            ((-45, 0), 0, 0, False, "nearest", "zeros", False, (1, 48, 77, 90)),
-            ((-45, 0), 0, 0, False, GridSampleMode.NEAREST, GridSamplePadMode.ZEROS, False, (1, 48, 77, 90)),
+            (np.pi / 2, -np.pi / 6, (0.0, np.pi), False, "bilinear", "border", False, (1, 87, 104, 109)),
+            (
+                np.pi / 2,
+                -np.pi / 6,
+                (0.0, np.pi),
+                False,
+                GridSampleMode.NEAREST,
+                GridSamplePadMode.BORDER,
+                False,
+                (1, 87, 104, 109),
+            ),
+            (
+                np.pi / 4,
+                (-np.pi / 9, np.pi / 4.5),
+                (np.pi / 9, np.pi / 6),
+                False,
+                "nearest",
+                "border",
+                True,
+                (1, 89, 105, 104),
+            ),
+            (
+                np.pi / 4,
+                (-np.pi / 9, np.pi / 4.5),
+                (np.pi / 9, np.pi / 6),
+                False,
+                GridSampleMode.NEAREST,
+                GridSamplePadMode.BORDER,
+                True,
+                (1, 89, 105, 104),
+            ),
+            (
+                0.0,
+                (2 * np.pi, 2.06 * np.pi),
+                (-np.pi / 180, np.pi / 180),
+                True,
+                "nearest",
+                "zeros",
+                True,
+                (1, 48, 64, 80),
+            ),
+            (
+                0.0,
+                (2 * np.pi, 2.06 * np.pi),
+                (-np.pi / 180, np.pi / 180),
+                True,
+                GridSampleMode.NEAREST,
+                GridSamplePadMode.ZEROS,
+                True,
+                (1, 48, 64, 80),
+            ),
+            ((-np.pi / 4, 0), 0, 0, False, "nearest", "zeros", False, (1, 48, 77, 90)),
+            ((-np.pi / 4, 0), 0, 0, False, GridSampleMode.NEAREST, GridSamplePadMode.ZEROS, False, (1, 48, 77, 90)),
         ]
     )
     def test_correct_shapes(self, x, y, z, keep_size, mode, padding_mode, align_corners, expected):
diff --git a/tests/test_rotate.py b/tests/test_rotate.py
--- a/tests/test_rotate.py
+++ b/tests/test_rotate.py
@@ -19,25 +19,25 @@
 from tests.utils import NumpyImageTestCase2D, NumpyImageTestCase3D
 
 TEST_CASES_2D = [
-    (30, False, "bilinear", "border", False),
-    (45, True, "bilinear", "border", False),
-    (-40, True, "nearest", "reflection", False),
-    (180, False, "nearest", "zeros", False),
-    (-90, False, "bilinear", "zeros", True),
+    (np.pi / 6, False, "bilinear", "border", False),
+    (np.pi / 4, True, "bilinear", "border", False),
+    (-np.pi / 4.5, True, "nearest", "reflection", False),
+    (np.pi, False, "nearest", "zeros", False),
+    (-np.pi / 2, False, "bilinear", "zeros", True),
 ]
 
 TEST_CASES_3D = [
-    (-90.0, True, "nearest", "border", False),
-    (45, True, "bilinear", "border", False),
-    (-40, True, "nearest", "reflection", False),
-    (180, False, "nearest", "zeros", False),
-    (-90, False, "bilinear", "zeros", False),
+    (-np.pi / 2, True, "nearest", "border", False),
+    (np.pi / 4, True, "bilinear", "border", False),
+    (-np.pi / 4.5, True, "nearest", "reflection", False),
+    (np.pi, False, "nearest", "zeros", False),
+    (-np.pi / 2, False, "bilinear", "zeros", False),
 ]
 
 TEST_CASES_SHAPE_3D = [
-    ([-90.0, 1.0, 2.0], "nearest", "border", False),
-    ([45, 0, 0], "bilinear", "border", False),
-    ([-40, -20, 20], "nearest", "reflection", False),
+    ([-np.pi / 2, 1.0, 2.0], "nearest", "border", False),
+    ([np.pi / 4, 0, 0], "bilinear", "border", False),
+    ([-np.pi / 4.5, -20, 20], "nearest", "reflection", False),
 ]
 
 
@@ -59,7 +59,15 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne
         expected = list()
         for channel in self.imt[0]:
             expected.append(
-                scipy.ndimage.rotate(channel, -angle, (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False)
+                scipy.ndimage.rotate(
+                    channel,
+                    -np.rad2deg(angle),
+                    (0, 1),
+                    not keep_size,
+                    order=_order,
+                    mode=_mode,
+                    prefilter=False,
+                )
             )
         expected = np.stack(expected).astype(np.float32)
         np.testing.assert_allclose(expected, rotated, atol=1e-1)
@@ -83,7 +91,15 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne
         expected = list()
         for channel in self.imt[0]:
             expected.append(
-                scipy.ndimage.rotate(channel, -angle, (1, 2), not keep_size, order=_order, mode=_mode, prefilter=False)
+                scipy.ndimage.rotate(
+                    channel,
+                    -np.rad2deg(angle),
+                    (1, 2),
+                    not keep_size,
+                    order=_order,
+                    mode=_mode,
+                    prefilter=False,
+                )
             )
         expected = np.stack(expected).astype(np.float32)
         np.testing.assert_allclose(expected, rotated, atol=1e-1)
diff --git a/tests/test_rotated.py b/tests/test_rotated.py
--- a/tests/test_rotated.py
+++ b/tests/test_rotated.py
@@ -19,19 +19,19 @@
 from tests.utils import NumpyImageTestCase2D, NumpyImageTestCase3D
 
 TEST_CASES_2D = [
-    (-30, False, "bilinear", "border", False),
-    (-45, True, "bilinear", "border", False),
-    (40, True, "nearest", "reflection", False),
-    (-180, False, "nearest", "zeros", False),
-    (90, False, "bilinear", "zeros", True),
+    (-np.pi / 6, False, "bilinear", "border", False),
+    (-np.pi / 4, True, "bilinear", "border", False),
+    (np.pi / 4.5, True, "nearest", "reflection", False),
+    (-np.pi, False, "nearest", "zeros", False),
+    (np.pi / 2, False, "bilinear", "zeros", True),
 ]
 
 TEST_CASES_3D = [
-    (-30, False, "bilinear", "border", False),
-    (-45, True, "bilinear", "border", False),
-    (40, True, "nearest", "reflection", False),
-    (-180, False, "nearest", "zeros", False),
-    (90, False, "bilinear", "zeros", True),
+    (-np.pi / 6, False, "bilinear", "border", False),
+    (-np.pi / 4, True, "bilinear", "border", False),
+    (np.pi / 4.5, True, "nearest", "reflection", False),
+    (-np.pi, False, "nearest", "zeros", False),
+    (np.pi / 2, False, "bilinear", "zeros", True),
 ]
 
 
@@ -50,12 +50,12 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne
         else:
             _mode = "constant"
         expected = scipy.ndimage.rotate(
-            self.imt[0, 0], -angle, (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
+            self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
         )
         np.testing.assert_allclose(expected, rotated["img"][0], atol=1e-3)
 
         expected = scipy.ndimage.rotate(
-            self.segn[0, 0], -angle, (0, 1), not keep_size, order=0, mode=_mode, prefilter=False
+            self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False
         )
         expected = np.stack(expected).astype(int)
         self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 20)
@@ -76,12 +76,12 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne
         else:
             _mode = "constant"
         expected = scipy.ndimage.rotate(
-            self.imt[0, 0], angle, (0, 2), not keep_size, order=_order, mode=_mode, prefilter=False
+            self.imt[0, 0], np.rad2deg(angle), (0, 2), not keep_size, order=_order, mode=_mode, prefilter=False
         )
         np.testing.assert_allclose(expected.astype(np.float32), rotated["img"][0], atol=1e-3)
 
         expected = scipy.ndimage.rotate(
-            self.segn[0, 0], angle, (0, 2), not keep_size, order=0, mode=_mode, prefilter=False
+            self.segn[0, 0], np.rad2deg(angle), (0, 2), not keep_size, order=0, mode=_mode, prefilter=False
         )
         expected = np.stack(expected).astype(int)
         self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 100)
@@ -102,12 +102,12 @@ def test_correct_results(self, angle, keep_size, mode, padding_mode, align_corne
         else:
             _mode = "constant"
         expected = scipy.ndimage.rotate(
-            self.imt[0, 0], -angle, (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
+            self.imt[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=_order, mode=_mode, prefilter=False
         )
         np.testing.assert_allclose(expected, rotated["img"][0], atol=1e-3)
 
         expected = scipy.ndimage.rotate(
-            self.segn[0, 0], -angle, (0, 1), not keep_size, order=0, mode=_mode, prefilter=False
+            self.segn[0, 0], -np.rad2deg(angle), (0, 1), not keep_size, order=0, mode=_mode, prefilter=False
         )
         expected = np.stack(expected).astype(int)
         self.assertLessEqual(np.count_nonzero(expected != rotated["seg"][0]), 100)