Skip to content

Commit 61f5092

Browse files
aymuos15ericspod
andauthored
Support configurable rotation order in create_rotate (#8963)
Fixes #6029 . ### Description `create_rotate` hard-coded 3D rotations to the intrinsic `Rx @ Ry @ Rz` composition. This adds a `rotate_order` parameter following the convention of `scipy.spatial.transform.Rotation.from_euler`: a string of up to three axes from `{x, y, z}`, where lower case selects extrinsic rotations (about the fixed world axes) and upper case selects intrinsic rotations (about the moving body axes). The default `"XYZ"` reproduces the previous behaviour exactly, so existing pipelines are unaffected. The name avoids collision with the spline interpolation order already selected via `mode`. The parameter is threaded through `functional.rotate`, `Rotate`, `RandRotate`, `AffineGrid`, `RandAffineGrid`, `Affine`, `RandAffine` and their dictionary variants. Invalid sequences raise `ValueError`, and 2D inputs ignore it. A new test module checks that the default matches the legacy matrix, that every supported axis sequence matches scipy for both the numpy and torch backends, that invalid sequences raise, that 2D inputs ignore the order, and that the `Rotate` transform honours it while remaining invertible. ### Types of changes - [x] Non-breaking change (fix or new feature that would not break existing functionality). - [x] New tests added to cover the changes. - [x] In-line docstrings updated. --------- Signed-off-by: Soumya Snigdha Kundu <soumya_snigdha.kundu@kcl.ac.uk> Co-authored-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
1 parent d43bf62 commit 61f5092

5 files changed

Lines changed: 201 additions & 29 deletions

File tree

monai/transforms/spatial/array.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,9 @@ class Rotate(InvertibleTransform, LazyTransform):
936936
the output data type is always ``float32``.
937937
lazy: a flag to indicate whether this transform should execute lazily or not.
938938
Defaults to False
939+
rotate_order: for 3D inputs, the order in which the axes are rotated about, following the convention of
940+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See
941+
:py:func:`monai.transforms.utils.create_rotate`. Defaults to ``"XYZ"`` (the legacy behaviour).
939942
"""
940943

941944
backend = [TransformBackends.TORCH]
@@ -949,6 +952,7 @@ def __init__(
949952
align_corners: bool = False,
950953
dtype: DtypeLike | torch.dtype = torch.float32,
951954
lazy: bool = False,
955+
rotate_order: str = "XYZ",
952956
) -> None:
953957
LazyTransform.__init__(self, lazy=lazy)
954958
self.angle = angle
@@ -957,6 +961,7 @@ def __init__(
957961
self.padding_mode: str = padding_mode
958962
self.align_corners = align_corners
959963
self.dtype = dtype
964+
self.rotate_order = rotate_order
960965

961966
def __call__(
962967
self,
@@ -1009,6 +1014,7 @@ def __call__(
10091014
_dtype,
10101015
lazy=lazy_,
10111016
transform_info=self.get_transform_info(),
1017+
rotate_order=self.rotate_order,
10121018
)
10131019

10141020
def inverse(self, data: torch.Tensor) -> torch.Tensor:
@@ -1741,6 +1747,10 @@ class AffineGrid(LazyTransform):
17411747
dimensions + 1.
17421748
lazy: a flag to indicate whether this transform should execute lazily or not.
17431749
Defaults to False
1750+
rotate_order: for 3D inputs, the order in which the axes are rotated about when building the
1751+
rotation from ``rotate_params``, following the convention of
1752+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See
1753+
:py:func:`monai.transforms.utils.create_rotate`. Defaults to ``"XYZ"`` (the legacy behaviour).
17441754
"""
17451755

17461756
backend = [TransformBackends.TORCH]
@@ -1756,6 +1766,7 @@ def __init__(
17561766
align_corners: bool = False,
17571767
affine: NdarrayOrTensor | None = None,
17581768
lazy: bool = False,
1769+
rotate_order: str = "XYZ",
17591770
) -> None:
17601771
LazyTransform.__init__(self, lazy=lazy)
17611772
self.rotate_params = rotate_params
@@ -1767,6 +1778,7 @@ def __init__(
17671778
self.dtype = _dtype if _dtype in (torch.float16, torch.float64, None) else torch.float32
17681779
self.align_corners = align_corners
17691780
self.affine = affine
1781+
self.rotate_order = rotate_order
17701782

17711783
def __call__(
17721784
self, spatial_size: Sequence[int] | None = None, grid: torch.Tensor | None = None, lazy: bool | None = None
@@ -1808,7 +1820,7 @@ def __call__(
18081820
if self.affine is None:
18091821
affine = torch.eye(spatial_dims + 1, device=_device)
18101822
if self.rotate_params:
1811-
affine @= create_rotate(spatial_dims, self.rotate_params, device=_device, backend=_b) # type: ignore[assignment]
1823+
affine @= create_rotate(spatial_dims, self.rotate_params, device=_device, backend=_b, rotate_order=self.rotate_order) # type: ignore[assignment]
18121824
if self.shear_params:
18131825
affine @= create_shear(spatial_dims, self.shear_params, device=_device, backend=_b) # type: ignore[assignment]
18141826
if self.translate_params:
@@ -2216,6 +2228,7 @@ def __init__(
22162228
align_corners: bool = False,
22172229
image_only: bool = False,
22182230
lazy: bool = False,
2231+
rotate_order: str = "XYZ",
22192232
) -> None:
22202233
"""
22212234
The affine transformations are applied in rotate, shear, translate, scale order.
@@ -2274,6 +2287,10 @@ def __init__(
22742287
image_only: if True return only the image volume, otherwise return (image, affine).
22752288
lazy: a flag to indicate whether this transform should execute lazily or not.
22762289
Defaults to False
2290+
rotate_order: for 3D inputs, the order in which the axes are rotated about when building the rotation
2291+
from ``rotate_params``, following the convention of
2292+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See
2293+
:py:func:`monai.transforms.utils.create_rotate`. Defaults to ``"XYZ"`` (the legacy behaviour).
22772294
"""
22782295
LazyTransform.__init__(self, lazy=lazy)
22792296
self.affine_grid = AffineGrid(
@@ -2286,6 +2303,7 @@ def __init__(
22862303
align_corners=align_corners,
22872304
device=device,
22882305
lazy=lazy,
2306+
rotate_order=rotate_order,
22892307
)
22902308
self.image_only = image_only
22912309
self.norm_coord = not normalized

monai/transforms/spatial/dictionary.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,7 @@ def __init__(
917917
align_corners: bool = False,
918918
allow_missing_keys: bool = False,
919919
lazy: bool = False,
920+
rotate_order: str = "XYZ",
920921
) -> None:
921922
"""
922923
Args:
@@ -969,6 +970,10 @@ def __init__(
969970
allow_missing_keys: don't raise exception if key is missing.
970971
lazy: a flag to indicate whether this transform should execute lazily or not.
971972
Defaults to False
973+
rotate_order: for 3D inputs, the order in which the axes are rotated about when building the rotation
974+
from ``rotate_params``, following the convention of
975+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See
976+
:py:func:`monai.transforms.utils.create_rotate`. Defaults to ``"XYZ"`` (the legacy behaviour).
972977
973978
See also:
974979
- :py:class:`monai.transforms.compose.MapTransform`
@@ -988,6 +993,7 @@ def __init__(
988993
dtype=dtype, # type: ignore
989994
align_corners=align_corners,
990995
lazy=lazy,
996+
rotate_order=rotate_order,
991997
)
992998
self.mode = ensure_tuple_rep(mode, len(self.keys))
993999
self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys))
@@ -1752,6 +1758,9 @@ class Rotated(MapTransform, InvertibleTransform, LazyTransform):
17521758
allow_missing_keys: don't raise exception if key is missing.
17531759
lazy: a flag to indicate whether this transform should execute lazily or not.
17541760
Defaults to False
1761+
rotate_order: for 3D inputs, the order in which the axes are rotated about, following the convention of
1762+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See
1763+
:py:func:`monai.transforms.utils.create_rotate`. Defaults to ``"XYZ"`` (the legacy behaviour).
17551764
"""
17561765

17571766
backend = Rotate.backend
@@ -1767,10 +1776,11 @@ def __init__(
17671776
dtype: Sequence[DtypeLike | torch.dtype] | DtypeLike | torch.dtype = np.float32,
17681777
allow_missing_keys: bool = False,
17691778
lazy: bool = False,
1779+
rotate_order: str = "XYZ",
17701780
) -> None:
17711781
MapTransform.__init__(self, keys, allow_missing_keys)
17721782
LazyTransform.__init__(self, lazy=lazy)
1773-
self.rotator = Rotate(angle=angle, keep_size=keep_size, lazy=lazy)
1783+
self.rotator = Rotate(angle=angle, keep_size=keep_size, lazy=lazy, rotate_order=rotate_order)
17741784

17751785
self.mode = ensure_tuple_rep(mode, len(self.keys))
17761786
self.padding_mode = ensure_tuple_rep(padding_mode, len(self.keys))

monai/transforms/spatial/functional.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,9 @@ def resize(
383383
return out.copy_meta_from(meta_info) if isinstance(out, MetaTensor) else out
384384

385385

386-
def rotate(img, angle, output_shape, mode, padding_mode, align_corners, dtype, lazy, transform_info):
386+
def rotate(
387+
img, angle, output_shape, mode, padding_mode, align_corners, dtype, lazy, transform_info, rotate_order="XYZ"
388+
):
387389
"""
388390
Functional implementation of rotate.
389391
This function operates eagerly or lazily according to
@@ -405,6 +407,9 @@ def rotate(img, angle, output_shape, mode, padding_mode, align_corners, dtype, l
405407
the output data type is always ``float32``.
406408
lazy: a flag that indicates whether the operation should be performed lazily or not
407409
transform_info: a dictionary with the relevant information pertaining to an applied transform.
410+
rotate_order: the order in which the axes are rotated about for 3D inputs, following the convention of
411+
:py:func:`scipy.spatial.transform.Rotation.from_euler`. See :py:func:`monai.transforms.utils.create_rotate`.
412+
Defaults to ``"XYZ"`` (the legacy behaviour). Ignored for 2D inputs.
408413
409414
"""
410415

@@ -413,7 +418,7 @@ def rotate(img, angle, output_shape, mode, padding_mode, align_corners, dtype, l
413418
if input_ndim not in (2, 3):
414419
raise ValueError(f"Unsupported image dimension: {input_ndim}, available options are [2, 3].")
415420
_angle = ensure_tuple_rep(angle, 1 if input_ndim == 2 else 3)
416-
transform = create_rotate(input_ndim, _angle)
421+
transform = create_rotate(input_ndim, _angle, rotate_order=rotate_order)
417422
if output_shape is None:
418423
corners = np.asarray(np.meshgrid(*[(0, dim) for dim in im_shape], indexing="ij")).reshape((len(im_shape), -1))
419424
corners = transform[:-1, :-1] @ corners # type: ignore

monai/transforms/utils.py

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,7 @@ def create_rotate(
864864
radians: Sequence[float] | float,
865865
device: torch.device | None = None,
866866
backend: str = TransformBackends.NUMPY,
867+
rotate_order: str = "XYZ",
867868
) -> NdarrayOrTensor:
868869
"""
869870
create a 2D or 3D rotation matrix
@@ -872,19 +873,33 @@ def create_rotate(
872873
spatial_dims: {``2``, ``3``} spatial rank
873874
radians: rotation radians
874875
when spatial_dims == 3, the `radians` sequence corresponds to
875-
rotation in the 1st, 2nd, and 3rd dim respectively.
876+
rotation about the axes named by ``rotate_order``, in the order they are listed.
876877
device: device to compute and store the output (when the backend is "torch").
877878
backend: APIs to use, ``numpy`` or ``torch``.
879+
rotate_order: the order in which the axes are rotated about when ``spatial_dims == 3``,
880+
following the convention of :py:func:`scipy.spatial.transform.Rotation.from_euler`.
881+
A string of up to three characters from ``{'x', 'y', 'z'}`` (or ``{'X', 'Y', 'Z'}``),
882+
where ``radians[i]`` is the angle applied about ``rotate_order[i]``. Lower case letters
883+
select extrinsic rotations (about the original fixed axes); upper case letters select
884+
intrinsic rotations (about the moving, body-fixed axes). The default ``"XYZ"``
885+
reproduces the legacy behaviour (intrinsic x, then y, then z). Ignored when
886+
``spatial_dims == 2``.
878887
879888
Raises:
880889
ValueError: When ``radians`` is empty.
881890
ValueError: When ``spatial_dims`` is not one of [2, 3].
891+
ValueError: When ``rotate_order`` is not a valid Euler axis sequence.
882892
883893
"""
884894
_backend = look_up_option(backend, TransformBackends)
885895
if _backend == TransformBackends.NUMPY:
886896
return _create_rotate(
887-
spatial_dims=spatial_dims, radians=radians, sin_func=np.sin, cos_func=np.cos, eye_func=np.eye
897+
spatial_dims=spatial_dims,
898+
radians=radians,
899+
sin_func=np.sin,
900+
cos_func=np.cos,
901+
eye_func=np.eye,
902+
order=rotate_order,
888903
)
889904
if _backend == TransformBackends.TORCH:
890905
return _create_rotate(
@@ -893,16 +908,46 @@ def create_rotate(
893908
sin_func=lambda th: torch.sin(torch.as_tensor(th, dtype=torch.float32, device=device)),
894909
cos_func=lambda th: torch.cos(torch.as_tensor(th, dtype=torch.float32, device=device)),
895910
eye_func=lambda rank: torch.eye(rank, device=device),
911+
order=rotate_order,
896912
)
897913
raise ValueError(f"backend {backend} is not supported")
898914

899915

916+
def _validate_euler_order(order: str, num_radians: int) -> None:
917+
"""
918+
Validate a scipy-style Euler axis sequence.
919+
920+
Args:
921+
order: the user-facing ``rotate_order`` value, a 1-3 character axis sequence.
922+
num_radians: number of rotation angles the sequence must accommodate.
923+
924+
Raises:
925+
ValueError: when ``order`` is not a valid Euler axis sequence for ``num_radians`` angles.
926+
"""
927+
if not isinstance(order, str):
928+
raise ValueError(f"`rotate_order` must be a string, got {type(order).__name__}.")
929+
if not 1 <= len(order) <= 3:
930+
raise ValueError(f"`rotate_order` must contain between 1 and 3 axes, got '{order}'.")
931+
if not (order.islower() or order.isupper()):
932+
raise ValueError(
933+
f"`rotate_order` must be all lower case (extrinsic) or all upper case (intrinsic), got '{order}'."
934+
)
935+
lowered = order.lower()
936+
if any(axis not in "xyz" for axis in lowered):
937+
raise ValueError(f"`rotate_order` axes must be from 'x', 'y', 'z' (any case), got '{order}'.")
938+
if any(lowered[i] == lowered[i + 1] for i in range(len(lowered) - 1)):
939+
raise ValueError(f"`rotate_order` must not repeat the same axis consecutively, got '{order}'.")
940+
if len(order) < num_radians:
941+
raise ValueError(f"`rotate_order` '{order}' is too short for {num_radians} rotation angle(s).")
942+
943+
900944
def _create_rotate(
901945
spatial_dims: int,
902946
radians: Sequence[float] | float,
903947
sin_func: Callable = np.sin,
904948
cos_func: Callable = np.cos,
905949
eye_func: Callable = np.eye,
950+
order: str = "XYZ",
906951
) -> NdarrayOrTensor:
907952
radians = ensure_tuple(radians)
908953
if spatial_dims == 2:
@@ -915,30 +960,25 @@ def _create_rotate(
915960
raise ValueError("radians must be non empty.")
916961

917962
if spatial_dims == 3:
918-
affine = None
919-
if len(radians) >= 1:
920-
sin_, cos_ = sin_func(radians[0]), cos_func(radians[0])
921-
affine = eye_func(4)
922-
affine[1, 1], affine[1, 2] = cos_, -sin_
923-
affine[2, 1], affine[2, 2] = sin_, cos_
924-
if len(radians) >= 2:
925-
sin_, cos_ = sin_func(radians[1]), cos_func(radians[1])
926-
if affine is None:
927-
raise ValueError("Affine should be a matrix.")
928-
_affine = eye_func(4)
929-
_affine[0, 0], _affine[0, 2] = cos_, sin_
930-
_affine[2, 0], _affine[2, 2] = -sin_, cos_
931-
affine = affine @ _affine
932-
if len(radians) >= 3:
933-
sin_, cos_ = sin_func(radians[2]), cos_func(radians[2])
934-
if affine is None:
935-
raise ValueError("Affine should be a matrix.")
936-
_affine = eye_func(4)
937-
_affine[0, 0], _affine[0, 1] = cos_, -sin_
938-
_affine[1, 0], _affine[1, 1] = sin_, cos_
939-
affine = affine @ _affine
940-
if affine is None:
963+
if len(radians) < 1:
941964
raise ValueError("radians must be non empty.")
965+
_validate_euler_order(order, len(radians))
966+
intrinsic = order.isupper()
967+
affine = eye_func(4)
968+
for axis, radian in zip(order.lower(), radians):
969+
sin_, cos_ = sin_func(radian), cos_func(radian)
970+
_affine = eye_func(4)
971+
if axis == "x":
972+
_affine[1, 1], _affine[1, 2] = cos_, -sin_
973+
_affine[2, 1], _affine[2, 2] = sin_, cos_
974+
elif axis == "y":
975+
_affine[0, 0], _affine[0, 2] = cos_, sin_
976+
_affine[2, 0], _affine[2, 2] = -sin_, cos_
977+
else: # axis == "z"
978+
_affine[0, 0], _affine[0, 1] = cos_, -sin_
979+
_affine[1, 0], _affine[1, 1] = sin_, cos_
980+
# intrinsic rotations post-multiply (body-fixed axes); extrinsic pre-multiply (world axes)
981+
affine = affine @ _affine if intrinsic else _affine @ affine
942982
return affine # type: ignore
943983

944984
raise ValueError(f"Unsupported spatial_dims: {spatial_dims}, available options are [2, 3].")

0 commit comments

Comments
 (0)