Source code for deepali.spatial.linear

r"""Linear transformation models."""

from __future__ import annotations

from collections import OrderedDict
import math
from typing import Callable, Optional, Union

import torch
from torch import Size, Tensor
from torch.nn import init

from deepali.core import affine as U
from deepali.core.grid import Grid
from deepali.core.linalg import normalize_quaternion
from deepali.core.linalg import quaternion_to_rotation_matrix
from deepali.core.linalg import rotation_matrix_to_quaternion
from deepali.core.tensor import as_float_tensor

from .base import LinearTransform
from .composite import SequentialTransform
from .parametric import InvertibleParametricTransform


[docs]class HomogeneousTransform(InvertibleParametricTransform, LinearTransform): r"""Arbitrary homogeneous coordinate transformation.""" def __init__( self: HomogeneousTransform, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: Homogeneous transform as tensor of shape ``(N, D, D + 1)``. """ super().__init__(grid, groups=groups, params=params) @property def data_shape(self: HomogeneousTransform) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((self.ndim, self.ndim + 1))
[docs] def matrix_(self: HomogeneousTransform, arg: Tensor) -> HomogeneousTransform: r"""Set transformation matrix.""" if not isinstance(arg, Tensor): raise TypeError("HomogeneousTransform.matrix() 'arg' must be tensor") if arg.ndim != 3: raise ValueError("HomogeneousTransform.matrix() 'arg' must be 3-dimensional tensor") return self.data_(arg)
[docs] def tensor(self: HomogeneousTransform) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D + 1)``. """ matrix = self.data() if self.invert: N = matrix.shape[0] D = matrix.shape[1] row = torch.zeros((1, 1, D + 1), dtype=matrix.dtype, device=matrix.device) row[..., -1] = 1 matrix = torch.cat((matrix, row.expand(N, 1, D + 1)), dim=1) matrix = torch.inverse(matrix) matrix = matrix.narrow(1, 0, D) matrix = matrix return matrix
[docs] def extra_repr(self: HomogeneousTransform) -> str: r"""Print current transformation.""" s = super().extra_repr() + ", matrix=" if self.params is None: s += "undef" else: s += f"{self.matrix().tolist()!r}" return s
[docs]class Translation(InvertibleParametricTransform, LinearTransform): r"""Translation.""" def __init__( self: Translation, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: Translation offsets as tensor of shape ``(N, D)`` or ``(N, D, 1)``. """ super().__init__(grid, groups=groups, params=params) @property def data_shape(self: Translation) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((self.ndim,))
[docs] def offset(self: Translation) -> Tensor: r"""Get current translation offset in cube units.""" return self.data()
[docs] @torch.no_grad() def offset_(self: Translation, arg: Tensor) -> Translation: r"""Reset parameters to given translation in cube units.""" if not isinstance(arg, Tensor): raise TypeError("Translation.offset() 'arg' must be tensor") params = as_float_tensor(arg) if params.isnan().any() or params.isinf().any(): raise ValueError("Translation.offset() 'arg' must not be nan or inf") self.data_(params) return self
[docs] def tensor(self: Translation) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, 1)``. """ offset = self.offset() if self.invert: offset = -offset return U.translation(offset)
[docs] def extra_repr(self: Translation) -> str: r"""Print current transformation.""" s = super().extra_repr() + ", offset=" if self.params is None: s += "undef" else: s += f"{self.offset().tolist()!r}" return s
[docs]class EulerRotation(InvertibleParametricTransform, LinearTransform): r"""Euler rotation.""" def __init__( self: EulerRotation, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, order: Optional[str] = None, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be 1 or equal to batch size. params: Rotation angles in degrees. This parameterization is adopted from MIRTK and ensures that rotation angles and scaling factors (percentage) are within a similar range of magnitude which is useful for direct optimization of these parameters. If parameters are predicted by a callable ``torch.nn.Module``, different output activations may be chosen before converting these to degrees. order: Order in which to compose elementary rotations. For example in 3D, "zxz" means that the first rotation occurs about z, the second about x, and the third rotation about z again. In 2D, this argument is ignored and a single rotation about z (plane normal) is applied. """ if grid.ndim < 2 or grid.ndim > 3: raise ValueError("EulerRotation() 'grid' must be 2- or 3-dimensional") super().__init__(grid, groups=groups, params=params) self.order = order @property def data_shape(self: EulerRotation) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((self.nangles,)) @property def nangles(self: EulerRotation) -> int: r"""Number of Euler angles.""" return 1 if self.ndim == 2 else self.ndim
[docs] def angles(self: EulerRotation) -> Tensor: r"""Get Euler angles in radians.""" params = self.data() if self.has_parameters(): params = params.tanh().mul(math.pi) return params
[docs] @torch.no_grad() def angles_(self: EulerRotation, arg: Tensor) -> EulerRotation: r"""Reset parameters to given Euler angles in radians.""" if not isinstance(arg, Tensor): raise TypeError("EulerRotation.angles() 'arg' must be tensor") shape = self.data_shape if arg.ndim != len(shape) + 1: raise ValueError( f"EulerRotation.angles() 'arg' must be {len(shape) + 1}-dimensional tensor" ) shape = (arg.shape[0],) + self.data_shape if arg.shape != shape: raise ValueError(f"EulerRotation.angles() 'arg' must have shape {shape!r}") params = as_float_tensor(arg) if self.has_parameters(): params = params.div(math.pi).atanh() if params.isnan().any(): raise ValueError("EulerRotation.angles() 'arg' must be in range [-pi, pi]") self.data_(params) return self
[docs] def matrix_(self: EulerRotation, arg: Tensor) -> EulerRotation: r"""Set rotation angles from rotation matrix.""" if not isinstance(arg, Tensor): raise TypeError("EulerRotation.matrix() 'arg' must be tensor") if arg.ndim != 3: raise ValueError("EulerRotation.matrix() 'arg' must be 3-dimensional tensor") shape = (arg.shape[0], 3, 3) if arg.shape != shape: raise ValueError(f"Rotation matrix must have shape {shape!r}") angles = U.euler_rotation_angles(arg, order=self.order) return self.angles_(angles)
[docs] def tensor(self: EulerRotation) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D)``. """ mat = U.euler_rotation_matrix(self.angles(), order=self.order) if self.invert: mat = mat.transpose(1, 2) return mat
[docs] def extra_repr(self: EulerRotation) -> str: r"""Print current transformation.""" s = super().extra_repr() + ", angles=" if self.params is None: s += "undef" else: s += f"{self.angles().tolist()!r}" return s
[docs]class QuaternionRotation(InvertibleParametricTransform, LinearTransform): r"""Quaternion based rotation in 3D.""" def __init__( self: QuaternionRotation, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: (normalized quaternion as 2-dimensional tensor of ``(N, 4)``. """ if grid.ndim != 3: raise ValueError("QuaternionRotation() 'grid' must be 3-dimensional") super().__init__(grid, groups=groups, params=params) @property def data_shape(self: QuaternionRotation) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((4,))
[docs] @torch.no_grad() def reset_parameters(self: QuaternionRotation) -> None: r"""Reset transformation parameters.""" params = self.params if isinstance(params, Tensor): params.copy_(torch.tensor([0, 0, 0, 1], dtype=params.dtype, device=params.device))
[docs] def quaternion(self: QuaternionRotation) -> Tensor: r"""Get rotation quaternion.""" params = self.data() return normalize_quaternion(params)
[docs] @torch.no_grad() def quaternion_(self: QuaternionRotation, arg: Tensor) -> QuaternionRotation: r"""Set rotation quaternion.""" if not isinstance(arg, Tensor): raise TypeError("QuaternionRotation.quaternion() 'arg' must be tensor") shape = self.data_shape if arg.ndim != len(shape) + 1: raise ValueError( f"QuaternionRotation.quaternion() 'arg' must be {len(shape) + 1}-dimensional tensor" ) shape = (arg.shape[0],) + self.data_shape if arg.shape != shape: raise ValueError(f"QuaternionRotation.quaternion() 'arg' must have shape {shape!r}") params = as_float_tensor(arg) params = normalize_quaternion(params) self.data_(params) return self
[docs] def matrix_(self: QuaternionRotation, arg: Tensor) -> QuaternionRotation: r"""Set rotation quaternion from rotation matrix.""" if not isinstance(arg, Tensor): raise TypeError("QuaternionRotation.matrix() 'arg' must be tensor") if arg.ndim != 3: raise ValueError("QuaternionRotation.matrix() 'arg' must be 3-dimensional tensor") shape = (arg.shape[0], 3, 3) if arg.shape != shape: raise ValueError(f"Rotation matrix must have shape {shape!r}") arg = rotation_matrix_to_quaternion(arg) return self.quaternion_(arg)
[docs] def tensor(self: QuaternionRotation) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D)``. """ q = self.data() mat = quaternion_to_rotation_matrix(q) if self.invert: mat = mat.transpose(1, 2) return mat
[docs] def extra_repr(self: QuaternionRotation) -> str: r"""Print current transformation.""" s = super().extra_repr() + ", q=" if self.params is None: s += "undef" else: s += f"{self.quaternion().tolist()!r}" return s
[docs]class IsotropicScaling(InvertibleParametricTransform, LinearTransform): r"""Isotropic scaling.""" def __init__( self: IsotropicScaling, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: Isotropic scaling factor as a percentage. This parameterization is adopted from MIRTK and ensures that rotation angles in degrees and scaling factors are within a similar range of magnitude which is useful for direct optimization of these parameters. If parameters are predicted by a callable ``torch.nn.Module``, different output activations may be chosen before converting these to percentages (i.e., multiplied by 100). """ super().__init__(grid, groups=groups, params=params) @property def data_shape(self: IsotropicScaling) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((1,))
[docs] @torch.no_grad() def reset_parameters(self: IsotropicScaling) -> None: r"""Reset transformation parameters.""" params = self.params if isinstance(params, Tensor): init.constant_(params, 1)
[docs] def scales(self: IsotropicScaling) -> Tensor: r"""Get scaling factors.""" params = self.data() if self.has_parameters(): params = params.sub(1).tanh().exp() return params
[docs] @torch.no_grad() def scales_(self: IsotropicScaling, arg: Tensor) -> IsotropicScaling: r"""Set transformation parameters from scaling factors.""" if not isinstance(arg, Tensor): raise TypeError("IsotropicScaling.scales() 'arg' must be tensor") shape = self.data_shape if arg.ndim != len(shape) + 1: raise ValueError( f"IsotropicScaling.scales() 'arg' must be {len(shape) + 1}-dimensional tensor" ) shape = (arg.shape[0],) + shape if arg.shape != shape: raise ValueError(f"IsotropicScaling.scales() 'arg' must have shape {shape!r}") params = as_float_tensor(arg) if self.has_parameters(): params = params.log().atanh().add(1) if params.isnan().any(): raise ValueError("IsotropicScaling.scales() 'arg' must be positive") self.data_(params) return self
[docs] def tensor(self: IsotropicScaling) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D)``. """ scales = self.scales() if self.invert: scales = 1 / scales return U.scaling_transform(scales.expand(scales.shape[0], self.ndim))
[docs] def extra_repr(self: IsotropicScaling) -> str: """Print current transformation.""" s = super().extra_repr() + ", scales=" if self.params is None: s += "undef" else: s += f"{self.scales().tolist()!r}" return s
[docs]class AnisotropicScaling(InvertibleParametricTransform, LinearTransform): r"""Anisotropic scaling.""" def __init__( self: AnisotropicScaling, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: Anisotropic scaling factors as percentages. This parameterization is adopted from MIRTK and ensures that rotation angles in degrees and scaling factors are within a similar range of magnitude which is useful for direct optimization of these parameters. If parameters are predicted by a callable ``torch.nn.Module``, different output activations may be chosen before converting these to percentages (i.e., multiplied by 100). """ super().__init__(grid, groups=groups, params=params) @property def data_shape(self: AnisotropicScaling) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((self.ndim,))
[docs] @torch.no_grad() def reset_parameters(self: AnisotropicScaling) -> None: r"""Reset transformation parameters.""" params = self.params if isinstance(params, Tensor): init.constant_(params, 1)
[docs] def scales(self: AnisotropicScaling) -> Tensor: r"""Get scaling factors.""" params = self.data() if self.has_parameters(): params = params.sub(1).tanh().exp() return params
[docs] @torch.no_grad() def scales_(self: AnisotropicScaling, arg: Tensor) -> AnisotropicScaling: r"""Set transformation parameters from scaling factors.""" if not isinstance(arg, Tensor): raise TypeError("AnisotropicScaling.scales() 'arg' must be tensor") shape = self.data_shape if arg.ndim != len(shape) + 1: raise ValueError( f"AnisotropicScaling.scales() 'arg' must be {len(shape) + 1}-dimensional tensor" ) shape = (arg.shape[0],) + shape if arg.shape != shape: raise ValueError(f"AnisotropicScaling.scales() 'arg' must have shape {shape!r}") params = as_float_tensor(arg) if self.has_parameters(): params = params.log().atanh().add(1) if params.isnan().any(): raise ValueError("AnisotropicScaling.scales() 'arg' must be positive") self.data_(params) return self
[docs] def tensor(self: AnisotropicScaling) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D)``. """ scales = self.scales() if self.invert: scales = 1 / scales return U.scaling_transform(scales)
[docs] def extra_repr(self: AnisotropicScaling) -> str: r"""Print current transformation.""" s = super().extra_repr() + ", scales=" if self.params is None: s += "undef" else: s += f"{self.scales().tolist()!r}" return s
[docs]class Shearing(InvertibleParametricTransform, LinearTransform): r"""Shear transformation.""" def __init__( self: Shearing, grid: Grid, groups: Optional[int] = None, params: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Grid domain on which transformation is defined. groups: Number of transformations. Must be either 1 or equal to batch size. params: Shearing angles in degrees. This parameterization is adopted from MIRTK and ensures that rotation angles and scaling factors (percentage) are within a similar range of magnitude which is useful for direct optimization of these parameters. If parameters are predicted by a callable ``torch.nn.Module``, different output activations may be chosen before converting these to degrees. """ if grid.ndim < 2 or grid.ndim > 3: raise ValueError("Shearing() 'grid' must be 2- or 3-dimensional'") super().__init__(grid, groups=groups, params=params) @property def data_shape(self: Shearing) -> Size: r"""Get shape of transformation parameters tensor.""" return Size((self.nangles,)) @property def nangles(self: Shearing) -> int: r"""Number of shear angles.""" return 1 if self.ndim == 2 else self.ndim
[docs] def angles(self: Shearing) -> Tensor: r"""Get shear angles in radians.""" params = self.data() if self.has_parameters(): params = params.tanh().mul(math.pi / 4) return params
[docs] @torch.no_grad() def angles_(self: Shearing, arg: Tensor) -> Shearing: r"""Set transformation parameters from shear angles in radians.""" if not isinstance(arg, Tensor): raise TypeError("Shearing.angles_() 'arg' must be tensor") shape = self.data_shape if arg.ndim != len(shape) + 1: raise ValueError( f"Shearing.angles_() 'arg' must be {len(shape) + 1}-dimensional tensor" ) shape = (arg.shape[0],) + shape if arg.shape != shape: raise ValueError(f"Shearing.angles_() 'arg' must have shape {shape!r}") params = as_float_tensor(arg) if self.has_parameters(): params = params.mul(4 / math.pi).atanh() if params.isnan().any(): raise ValueError("Shear 'angles' must be in range [-pi/4, pi/4]") self.data_(params) return self
[docs] def tensor(self: Shearing) -> Tensor: r"""Get tensor representation of this transformation Returns: Batch of homogeneous transformation matrices as tensor of shape ``(N, D, D)``. """ mat = U.shear_matrix(self.angles()) if self.invert: mat = torch.inverse(mat) return mat
[docs] def extra_repr(self: Shearing) -> str: r"""Print current transformation.""" s = super().extra_repr() + "angles=" if self.params is None: s += "undef" else: s += f"{self.angles().tolist()!r}" return s
[docs]class RigidTransform(SequentialTransform): r"""Rigid transformation.""" def __init__( self: RigidTransform, grid: Grid, groups: Optional[int] = None, rotation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, translation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Domain with respect to which transformation is defined. groups: Number of transformations ``N``. rotation: Parameters of ``EulerRotation``. translation: Parameters of ``Translation``. """ transforms = OrderedDict() transforms["rotation"] = EulerRotation(grid, groups=groups, params=rotation) transforms["translation"] = Translation(grid, groups=groups, params=translation) super().__init__(transforms) @property def rotation(self) -> EulerRotation: return self._transforms["rotation"] @property def translation(self) -> Translation: return self._transforms["translation"]
[docs]class RigidQuaternionTransform(SequentialTransform): r"""Rigid transformation parameterized by rotation quaternion.""" def __init__( self: RigidQuaternionTransform, grid: Grid, groups: Optional[int] = None, rotation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, translation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Domain with respect to which transformation is defined. groups: Number of transformations ``N``. rotation: Parameters of ``QuaternionRotation``. translation: Parameters of ``Translation``. """ transforms = OrderedDict() transforms["rotation"] = QuaternionRotation(grid, groups=groups, params=rotation) transforms["translation"] = Translation(grid, groups=groups, params=translation) super().__init__(transforms) @property def rotation(self) -> QuaternionRotation: return self._transforms["rotation"] @property def translation(self) -> Translation: return self._transforms["translation"]
[docs]class SimilarityTransform(SequentialTransform): r"""Similarity transformation with isotropic scaling.""" def __init__( self: SimilarityTransform, grid: Grid, groups: Optional[int] = None, scaling: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, rotation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, translation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Domain with respect to which transformation is defined. groups: Number of transformations ``N``. scaling: Parameters of ``IsotropicScaling``. rotation: Parameters of ``EulerRotation``. translation: Parameters of ``Translation``. """ transforms = OrderedDict() transforms["scaling"] = IsotropicScaling(grid, groups=groups, params=scaling) transforms["rotation"] = EulerRotation(grid, groups=groups, params=rotation) transforms["translation"] = Translation(grid, groups=groups, params=translation) super().__init__(transforms) @property def rotation(self) -> EulerRotation: return self._transforms["rotation"] @property def scaling(self) -> IsotropicScaling: return self._transforms["scaling"] @property def translation(self) -> Translation: return self._transforms["translation"]
[docs]class AffineTransform(SequentialTransform): r"""Affine transformation without shearing.""" def __init__( self: AffineTransform, grid: Grid, groups: Optional[int] = None, scaling: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, rotation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, translation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Domain with respect to which transformation is defined. groups: Number of transformations ``N``. scaling: Parameters of ``AnisotropicScaling``. rotation: Parameters of ``EulerRotation``. translation: Parameters of ``Translation``. """ transforms = OrderedDict() transforms["scaling"] = AnisotropicScaling(grid, groups=groups, params=scaling) transforms["rotation"] = EulerRotation(grid, groups=groups, params=rotation) transforms["translation"] = Translation(grid, groups=groups, params=translation) super().__init__(transforms) @property def rotation(self) -> EulerRotation: return self._transforms["rotation"] @property def scaling(self) -> AnisotropicScaling: return self._transforms["scaling"] @property def translation(self) -> Translation: return self._transforms["translation"]
[docs]class FullAffineTransform(SequentialTransform): r"""Affine transformation including shearing.""" def __init__( self: FullAffineTransform, grid: Grid, groups: Optional[int] = None, scaling: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, shearing: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, rotation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, translation: Optional[Union[bool, Tensor, Callable[..., Tensor]]] = True, ) -> None: r"""Initialize transformation parameters. Args: grid: Domain with respect to which transformation is defined. groups: Number of transformations ``N``. scaling: Parameters of ``AnisotropicScaling``. shearing: Parameters of ``Shearing``. rotation: Parameters of ``EulerRotation``. translation: Parameters of ``Translation``. """ transforms = OrderedDict() transforms["scaling"] = AnisotropicScaling(grid, groups=groups, params=scaling) transforms["shearing"] = Shearing(grid, groups=groups, params=shearing) transforms["rotation"] = EulerRotation(grid, groups=groups, params=rotation) transforms["translation"] = Translation(grid, groups=groups, params=translation) super().__init__(transforms) @property def rotation(self) -> EulerRotation: return self._transforms["rotation"] @property def scaling(self) -> AnisotropicScaling: return self._transforms["scaling"] @property def shearing(self) -> Shearing: return self._transforms["shearing"] @property def translation(self) -> Translation: return self._transforms["translation"]