transformnd.transforms

Implementations of some common transforms.

 1"""Implementations of some common transforms."""
 2
 3from .affine import Affine
 4from .reflection import Reflect
 5from .simple import Identity, Scale, Translate
 6from .map_axis import MapAxis
 7from .bijection import Bijection
 8from .project_axis import ProjectAxis
 9from .by_dimension import ByDimension, SubTransform
10from .vector_field import Coordinates, Displacements
11from .moving_least_squares import MovingLeastSquares
12from .thinplate import ThinPlateSplines
13
14__all__ = [
15    "Affine",
16    "Identity",
17    "ProjectAxis",
18    "Reflect",
19    "Scale",
20    "Translate",
21    "MapAxis",
22    "Bijection",
23    "ByDimension",
24    "SubTransform",
25    "Coordinates",
26    "Displacements",
27    "MovingLeastSquares",
28    "ThinPlateSplines",
29]
class Affine(transformnd.base.Transform[~ArrayT]):
 23class Affine(Transform[ArrayT]):
 24    """Affine transformation using an augmented matrix.
 25
 26    The transformation matrix is stored as a NumPy array (backend-neutral).
 27    At apply()-time it is converted to the input coords' backend and device,
 28    so the transform works transparently with NumPy, JAX, PyTorch, CuPy, etc.
 29
 30    Affines can be composed by matrix multiplication: `affine2 @ affine1`.
 31    Note that the right hand transformation is effectively applied to the coordinates first,
 32    so `(aff2 @ aff1).apply(coords) == (aff1 | aff2).apply(coords)`.
 33    """
 34
 35    def __init__(
 36        self,
 37        matrix: ArrayLike,
 38        *,
 39        spaces: Spaces = Spaces(None, None),
 40    ):
 41        """
 42        Parameters
 43        ----------
 44        matrix
 45            Affine transformation matrix,
 46            i.e. a 2D array-like with shape `(Do + 1, Di + 1)`,
 47            where the bottom row is all 0s except in the rightmost column, which is 1.
 48        spaces
 49            Optional source and target spaces
 50
 51        Raises
 52        ------
 53        ValueError
 54            Malformed matrix.
 55        """
 56        m = as_floats(matrix)
 57        if m.ndim != 2:
 58            raise ValueError("Affine matrix must be 2D")
 59
 60        bottom_row = m[-1, :]
 61        expected = np.zeros_like(bottom_row)
 62        expected[-1] = 1
 63        if not np.allclose(bottom_row, expected):
 64            raise ValueError(
 65                f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})."
 66            )
 67
 68        super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces)
 69
 70        self.matrix: np.ndarray = m
 71
 72        self._linear_map: np.ndarray | None = m[:-1, :-1]
 73        if is_square(self._linear_map) and np.allclose(
 74            self._linear_map, np.eye(self._linear_map.shape[0], dtype=self.matrix.dtype)
 75        ):
 76            self._linear_map = None
 77
 78        self._translation: np.ndarray | None = self.matrix[:-1, -1]
 79        if np.allclose(np.zeros_like(self._translation), self._translation):
 80            self._translation = None
 81
 82    def to_affine(self) -> Self | None:
 83        return self
 84
 85    def cast_matrix(self, namespace, device) -> ArrayT:
 86        return namespace.asarray(self.matrix, device=device)
 87
 88    def apply(self, coords: ArrayT) -> ArrayT:
 89        coords = self._validate_coords(coords)
 90        xp = array_namespace(coords)
 91        d = xp_device(coords)
 92
 93        out = coords
 94
 95        if self._linear_map is not None:
 96            lm = xp.asarray(self._linear_map, device=d)
 97            out = coords @ xp.matrix_transpose(lm)
 98
 99        if self._translation is not None:
100            t = xp.asarray(self._translation, device=d)
101            if self._linear_map is None:
102                out = coords + t
103            else:
104                out += t
105
106        ## Padding and then unpadding the coords is slower, especially in C order
107        # coords = xp.concatenate(
108        #     [coords, xp.ones((coords.shape[0], 1), dtype=coords.dtype)],  # type: ignore[attr-defined]
109        #     axis=1,
110        # )
111        # out: ArrayT = (coords @ m.T)[:, :-1]  # type: ignore[attr-defined]
112
113        return out
114
115    def invert(self) -> Self | None:
116        try:
117            inv = np.linalg.inv(self.matrix)
118        except np.linalg.LinAlgError:
119            return None
120
121        return type(self)(
122            inv,
123            spaces=self.spaces.invert(),
124        )
125
126    def __matmul__(self, rhs: Affine[ArrayT]) -> Affine[ArrayT]:
127        """Compose two affine transforms by matrix multiplication.
128
129        As with affine matrices the right hand operand is effectively applied first.
130
131        Parameters
132        ----------
133        rhs
134            The right-hand affine transform.
135
136        Returns
137        -------
138        Affine[ArrayT]
139            The composed affine transform.
140
141        Raises
142        ------
143        ValueError
144            Incompatible transforms.
145        """
146        if not isinstance(rhs, Affine):
147            return NotImplemented
148        if self.ndims.source != rhs.ndims.target:
149            raise ValueError(
150                "Cannot compose affine transformations of different dimensionality"
151            )
152
153        # this ordering looks wrong but this is the way affine transforms get combined;
154        # the sequence transform A followed by transform B is expressed B @ A
155        if not none_eq(self.spaces.source, rhs.spaces.target):
156            raise ValueError("Affine transforms do not share a space")
157        return Affine(
158            self.matrix @ rhs.matrix,
159            spaces=Spaces(rhs.spaces.source, self.spaces.target),
160        )
161
162    def to_device(self, xp: ModuleType, device: str | None = None) -> "Affine[ArrayT]":
163        """Return a copy with the matrix placed on the given device/backend.
164
165        Use this before a tight apply() loop to avoid per-call host-to-device
166        transfers when coords live on GPU.
167
168        Parameters
169        ----------
170        xp
171            Target array namespace (e.g. jax.numpy, torch).
172        device
173            Target device (e.g. from array_api_compat.device(array)).
174
175        Returns
176        -------
177        Affine[ArrayT]
178            New instance with matrix on the target device.
179        """
180        result = copy(self)
181        result.matrix = xp.asarray(self.matrix, device=device)
182        return result
183
184    @classmethod
185    def from_linear_map(
186        cls,
187        linear_map: ArrayLike,
188        translation: ArrayLike | None = None,
189        *,
190        spaces: Spaces = Spaces(None, None),
191    ) -> Affine[ArrayT]:
192        """Create an augmented affine matrix from a linear map,
193        with an optional translation.
194
195        Parameters
196        ----------
197        linear_map
198            Shape `(Di, Do)`
199        translation
200            Translation to add to the matrix, by default 0
201        spaces
202            Optional source and target spaces
203
204        Returns
205        -------
206        Affine[ArrayT]
207            The affine transform.
208
209        Raises
210        ------
211        ValueError
212            If shapes are inconsistent.
213        """
214        lin_map = as_floats(linear_map)
215        if lin_map.ndim != 2:
216            raise ValueError(f"Linear map must be 2D; got shape {lin_map.shape}")
217        matrix = np.zeros_like(
218            lin_map, shape=(lin_map.shape[0] + 1, lin_map.shape[1] + 1)
219        )
220        matrix[:-1, :-1] = lin_map
221        matrix[-1, -1] = 1
222        if translation is not None:
223            t = as_floats(translation)
224            if len(t) != lin_map.shape[0]:
225                raise ValueError(
226                    "Translation array must be the same length as linear map columns"
227                )
228            matrix[:-1, -1] = translation
229        return cls(matrix, spaces=spaces)
230
231    @classmethod
232    def identity(
233        cls,
234        ndim: int,
235        *,
236        spaces: Spaces = Spaces(None, None),
237    ) -> Affine[ArrayT]:
238        """Create an identity affine transformation.
239
240        Parameters
241        ----------
242        ndim
243            The dimensionality of the transform.
244        spaces
245            Optional source and target spaces
246
247        Returns
248        -------
249        Affine[ArrayT]
250            The identity affine transform.
251        """
252        return cls(np.eye(ndim + 1), spaces=spaces)
253
254    @classmethod
255    def translation(
256        cls,
257        translation: ArrayLike,
258        *,
259        spaces: Spaces = Spaces(None, None),
260    ) -> Affine[ArrayT]:
261        """Create an affine translation.
262
263        Parameters
264        ----------
265        translation
266            D-length array of translation values.
267        spaces
268            Optional source and target spaces
269
270        Returns
271        -------
272        Affine[ArrayT]
273            The translation affine transform.
274
275        Raises
276        ------
277        ValueError
278            If translation array is not 1D.
279        """
280        t = as_floats(translation)
281        if t.ndim != 1:
282            raise ValueError(f"Translation array must be 1D; got shape {t.shape}")
283        m = np.eye(len(t) + 1, dtype=t.dtype)
284        m[:-1, -1] = t
285        return cls(m, spaces=spaces)
286
287    @classmethod
288    def scaling(
289        cls,
290        scale: ArrayLike,
291        *,
292        spaces: Spaces = Spaces(None, None),
293    ) -> Affine[ArrayT]:
294        """Create an affine scaling.
295
296        Parameters
297        ----------
298        scale
299            D-length array of scaling factors.
300        spaces
301            Optional source and target spaces
302
303        Returns
304        -------
305        Affine[ArrayT]
306            The scaling affine transform.
307
308        Raises
309        ------
310        ValueError
311            If scale array is not 1D.
312        """
313        s = as_floats(scale)
314        if s.ndim != 1:
315            raise ValueError(f"Scale array must be 1D; got shape {s.shape}")
316        return cls.from_linear_map(np.diag(s), spaces=spaces)
317
318    @classmethod
319    def reflection(
320        cls,
321        axis: Union[int, Container[int]],
322        ndim: int,
323        *,
324        spaces: Spaces = Spaces(None, None),
325    ) -> Affine[ArrayT]:
326        """Create an affine reflection.
327
328        Parameters
329        ----------
330        axis
331            A single axis or multiple to reflect in.
332        ndim
333            How many dimensions to work in.
334        spaces
335            Optional source and target spaces
336
337        Returns
338        -------
339        Affine[ArrayT]
340            The reflection affine transform.
341        """
342        if isinstance(axis, (int, np.integer)):
343            axis = [axis]
344        values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)])
345        return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces)
346
347    @classmethod
348    def rotation2(
349        cls,
350        rotation: float,
351        degrees: bool = True,
352        clockwise: bool = False,
353        *,
354        spaces: Spaces = Spaces(None, None),
355    ) -> Affine[ArrayT]:
356        """Create a 2D affine rotation.
357
358        Parameters
359        ----------
360        rotation
361            Angle to rotate.
362        degrees
363            Whether rotation is in degrees (rather than radians), by default True
364        clockwise
365            Whether rotation is clockwise, by default False
366        spaces
367            Optional source and target spaces
368
369        Returns
370        -------
371        Affine[ArrayT]
372            The rotation affine transform.
373        """
374        if degrees:
375            rotation = math.radians(rotation)
376        if clockwise:
377            rotation *= -1
378        c, s = math.cos(rotation), math.sin(rotation)
379        return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces)
380
381    @classmethod
382    def rotation3(
383        cls,
384        rotation: Union[float, tuple[float, float, float]],
385        degrees: bool = True,
386        clockwise: bool = False,
387        order: tuple[int, int, int] = (0, 1, 2),
388        *,
389        spaces: Spaces = Spaces(None, None),
390    ) -> Affine[ArrayT]:
391        """Create a 3D affine rotation.
392
393        Parameters
394        ----------
395        rotation
396            Either a single rotation for all axes, or 1 for each.
397        degrees
398            Whether rotation is in degrees (rather than radians), by default True
399        clockwise
400            Whether rotation is clockwise, by default False
401        order
402            What order to apply the rotations, by default (0, 1, 2)
403        spaces
404            Optional source and target spaces
405
406        Returns
407        -------
408        Affine[ArrayT]
409            The rotation affine transform.
410
411        Raises
412        ------
413        ValueError
414            Incompatible order.
415        """
416        if isinstance(rotation, (int, float)):
417            r = [rotation] * 3
418        else:
419            r = list(rotation)
420
421        if degrees:
422            r = [math.radians(x) for x in r]
423        if clockwise:
424            r = [-x for x in r]
425
426        if len(order) != 3 or set(order) != {0, 1, 2}:
427            raise ValueError("Order must contain only 0, 1, 2 in any order.")
428
429        c0, s0 = math.cos(r[0]), math.sin(r[0])
430        c1, s1 = math.cos(r[1]), math.sin(r[1])
431        c2, s2 = math.cos(r[2]), math.sin(r[2])
432
433        rots = [
434            np.array([[1, 0, 0], [0, c0, -s0], [0, s0, c0]]),
435            np.array([[c1, 0, s1], [0, 1, 0], [-s1, 0, c1]]),
436            np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]),
437        ]
438        rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]]
439        return cls.from_linear_map(rot, spaces=spaces)
440
441    @classmethod
442    def shearing(
443        cls,
444        factor: Union[float, np.ndarray],
445        ndim: int | None = None,
446        *,
447        spaces: Spaces = Spaces(None, None),
448    ) -> Affine[ArrayT]:
449        """Create an affine shear.
450
451        `factor` can be a scalar to broadcast to all dimensions,
452        or a D-length list of D-1 lists.
453        The first inner list contains the shear factors in the first dimension
454        for all *but* the first dimension.
455        The second inner list contains the shear factors in the second dimension
456        for all the *but* the second dimension, etc.
457
458        Parameters
459        ----------
460        factor
461            Shear scale factors; see above for more details.
462        ndim
463            If factor is scalar, broadcast to this many dimensions, by default None
464        spaces
465            Optional source and target spaces
466
467        Returns
468        -------
469        Affine[ArrayT]
470            The shearing affine transform.
471
472        Raises
473        ------
474        ValueError
475            Incompatible factor.
476        """
477        if isinstance(factor, (int, float, complex)):
478            if ndim is None:
479                raise ValueError("If factor is scalar, ndim must be defined")
480            s = np.full((ndim, ndim - 1), factor)
481        else:
482            s = np.asarray(factor)
483            if s.ndim != 2 or s.shape[0] != s.shape[1] + 1:
484                raise ValueError("Factor must be of shape (D, D-1)")
485            ndim = s.shape[0]
486
487        assert ndim is not None
488
489        m = np.eye(ndim, dtype=s.dtype)
490        for col_idx in range(m.shape[1]):
491            it = iter(s[col_idx])
492            for row_idx in range(m.shape[0] - 1):
493                if m[row_idx, col_idx] == 0:
494                    m[row_idx, col_idx] = next(it)
495        return cls.from_linear_map(m, spaces=spaces)
496
497    def __eq__(self, other: object) -> bool:
498        if not isinstance(other, Affine):
499            return NotImplemented
500        return np.array_equal(self.matrix, other.matrix) and self.spaces == other.spaces
501
502    def is_identity(self) -> bool:
503        xp = array_namespace(self.matrix)
504        sh = xp.shape(self.matrix)
505        if sh[0] != sh[1]:
506            return False
507        identity = xp.eye(sh[0], dtype=self.matrix.dtype, device=self.matrix.device)
508        return xp.all(xp.equal(self.matrix, identity))

Affine transformation using an augmented matrix.

The transformation matrix is stored as a NumPy array (backend-neutral). At apply()-time it is converted to the input coords' backend and device, so the transform works transparently with NumPy, JAX, PyTorch, CuPy, etc.

Affines can be composed by matrix multiplication: affine2 @ affine1. Note that the right hand transformation is effectively applied to the coordinates first, so (aff2 @ aff1).apply(coords) == (aff1 | aff2).apply(coords).

Affine( matrix: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
35    def __init__(
36        self,
37        matrix: ArrayLike,
38        *,
39        spaces: Spaces = Spaces(None, None),
40    ):
41        """
42        Parameters
43        ----------
44        matrix
45            Affine transformation matrix,
46            i.e. a 2D array-like with shape `(Do + 1, Di + 1)`,
47            where the bottom row is all 0s except in the rightmost column, which is 1.
48        spaces
49            Optional source and target spaces
50
51        Raises
52        ------
53        ValueError
54            Malformed matrix.
55        """
56        m = as_floats(matrix)
57        if m.ndim != 2:
58            raise ValueError("Affine matrix must be 2D")
59
60        bottom_row = m[-1, :]
61        expected = np.zeros_like(bottom_row)
62        expected[-1] = 1
63        if not np.allclose(bottom_row, expected):
64            raise ValueError(
65                f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})."
66            )
67
68        super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces)
69
70        self.matrix: np.ndarray = m
71
72        self._linear_map: np.ndarray | None = m[:-1, :-1]
73        if is_square(self._linear_map) and np.allclose(
74            self._linear_map, np.eye(self._linear_map.shape[0], dtype=self.matrix.dtype)
75        ):
76            self._linear_map = None
77
78        self._translation: np.ndarray | None = self.matrix[:-1, -1]
79        if np.allclose(np.zeros_like(self._translation), self._translation):
80            self._translation = None
Parameters
  • matrix: Affine transformation matrix, i.e. a 2D array-like with shape (Do + 1, Di + 1), where the bottom row is all 0s except in the rightmost column, which is 1.
  • spaces: Optional source and target spaces
Raises
  • ValueError: Malformed matrix.
def to_affine(self) -> Optional[Self]:
82    def to_affine(self) -> Self | None:
83        return self

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
 88    def apply(self, coords: ArrayT) -> ArrayT:
 89        coords = self._validate_coords(coords)
 90        xp = array_namespace(coords)
 91        d = xp_device(coords)
 92
 93        out = coords
 94
 95        if self._linear_map is not None:
 96            lm = xp.asarray(self._linear_map, device=d)
 97            out = coords @ xp.matrix_transpose(lm)
 98
 99        if self._translation is not None:
100            t = xp.asarray(self._translation, device=d)
101            if self._linear_map is None:
102                out = coords + t
103            else:
104                out += t
105
106        ## Padding and then unpadding the coords is slower, especially in C order
107        # coords = xp.concatenate(
108        #     [coords, xp.ones((coords.shape[0], 1), dtype=coords.dtype)],  # type: ignore[attr-defined]
109        #     axis=1,
110        # )
111        # out: ArrayT = (coords @ m.T)[:, :-1]  # type: ignore[attr-defined]
112
113        return out

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def invert(self) -> Optional[Self]:
115    def invert(self) -> Self | None:
116        try:
117            inv = np.linalg.inv(self.matrix)
118        except np.linalg.LinAlgError:
119            return None
120
121        return type(self)(
122            inv,
123            spaces=self.spaces.invert(),
124        )

Invert the transformation, returning None if not possible.

def to_device( self, xp: module, device: str | None = None) -> Affine[~ArrayT]:
162    def to_device(self, xp: ModuleType, device: str | None = None) -> "Affine[ArrayT]":
163        """Return a copy with the matrix placed on the given device/backend.
164
165        Use this before a tight apply() loop to avoid per-call host-to-device
166        transfers when coords live on GPU.
167
168        Parameters
169        ----------
170        xp
171            Target array namespace (e.g. jax.numpy, torch).
172        device
173            Target device (e.g. from array_api_compat.device(array)).
174
175        Returns
176        -------
177        Affine[ArrayT]
178            New instance with matrix on the target device.
179        """
180        result = copy(self)
181        result.matrix = xp.asarray(self.matrix, device=device)
182        return result

Return a copy with the matrix placed on the given device/backend.

Use this before a tight apply() loop to avoid per-call host-to-device transfers when coords live on GPU.

Parameters
  • xp: Target array namespace (e.g. jax.numpy, torch).
  • device: Target device (e.g. from array_api_compat.device(array)).
Returns
  • Affine[ArrayT]: New instance with matrix on the target device.
@classmethod
def from_linear_map( cls, linear_map: ArrayLike, translation: ArrayLike | None = None, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
184    @classmethod
185    def from_linear_map(
186        cls,
187        linear_map: ArrayLike,
188        translation: ArrayLike | None = None,
189        *,
190        spaces: Spaces = Spaces(None, None),
191    ) -> Affine[ArrayT]:
192        """Create an augmented affine matrix from a linear map,
193        with an optional translation.
194
195        Parameters
196        ----------
197        linear_map
198            Shape `(Di, Do)`
199        translation
200            Translation to add to the matrix, by default 0
201        spaces
202            Optional source and target spaces
203
204        Returns
205        -------
206        Affine[ArrayT]
207            The affine transform.
208
209        Raises
210        ------
211        ValueError
212            If shapes are inconsistent.
213        """
214        lin_map = as_floats(linear_map)
215        if lin_map.ndim != 2:
216            raise ValueError(f"Linear map must be 2D; got shape {lin_map.shape}")
217        matrix = np.zeros_like(
218            lin_map, shape=(lin_map.shape[0] + 1, lin_map.shape[1] + 1)
219        )
220        matrix[:-1, :-1] = lin_map
221        matrix[-1, -1] = 1
222        if translation is not None:
223            t = as_floats(translation)
224            if len(t) != lin_map.shape[0]:
225                raise ValueError(
226                    "Translation array must be the same length as linear map columns"
227                )
228            matrix[:-1, -1] = translation
229        return cls(matrix, spaces=spaces)

Create an augmented affine matrix from a linear map, with an optional translation.

Parameters
  • linear_map: Shape (Di, Do)
  • translation: Translation to add to the matrix, by default 0
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The affine transform.
Raises
  • ValueError: If shapes are inconsistent.
@classmethod
def identity( cls, ndim: int, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
231    @classmethod
232    def identity(
233        cls,
234        ndim: int,
235        *,
236        spaces: Spaces = Spaces(None, None),
237    ) -> Affine[ArrayT]:
238        """Create an identity affine transformation.
239
240        Parameters
241        ----------
242        ndim
243            The dimensionality of the transform.
244        spaces
245            Optional source and target spaces
246
247        Returns
248        -------
249        Affine[ArrayT]
250            The identity affine transform.
251        """
252        return cls(np.eye(ndim + 1), spaces=spaces)

Create an identity affine transformation.

Parameters
  • ndim: The dimensionality of the transform.
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The identity affine transform.
@classmethod
def translation( cls, translation: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
254    @classmethod
255    def translation(
256        cls,
257        translation: ArrayLike,
258        *,
259        spaces: Spaces = Spaces(None, None),
260    ) -> Affine[ArrayT]:
261        """Create an affine translation.
262
263        Parameters
264        ----------
265        translation
266            D-length array of translation values.
267        spaces
268            Optional source and target spaces
269
270        Returns
271        -------
272        Affine[ArrayT]
273            The translation affine transform.
274
275        Raises
276        ------
277        ValueError
278            If translation array is not 1D.
279        """
280        t = as_floats(translation)
281        if t.ndim != 1:
282            raise ValueError(f"Translation array must be 1D; got shape {t.shape}")
283        m = np.eye(len(t) + 1, dtype=t.dtype)
284        m[:-1, -1] = t
285        return cls(m, spaces=spaces)

Create an affine translation.

Parameters
  • translation: D-length array of translation values.
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The translation affine transform.
Raises
  • ValueError: If translation array is not 1D.
@classmethod
def scaling( cls, scale: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
287    @classmethod
288    def scaling(
289        cls,
290        scale: ArrayLike,
291        *,
292        spaces: Spaces = Spaces(None, None),
293    ) -> Affine[ArrayT]:
294        """Create an affine scaling.
295
296        Parameters
297        ----------
298        scale
299            D-length array of scaling factors.
300        spaces
301            Optional source and target spaces
302
303        Returns
304        -------
305        Affine[ArrayT]
306            The scaling affine transform.
307
308        Raises
309        ------
310        ValueError
311            If scale array is not 1D.
312        """
313        s = as_floats(scale)
314        if s.ndim != 1:
315            raise ValueError(f"Scale array must be 1D; got shape {s.shape}")
316        return cls.from_linear_map(np.diag(s), spaces=spaces)

Create an affine scaling.

Parameters
  • scale: D-length array of scaling factors.
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The scaling affine transform.
Raises
  • ValueError: If scale array is not 1D.
@classmethod
def reflection( cls, axis: Union[int, Container[int]], ndim: int, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
318    @classmethod
319    def reflection(
320        cls,
321        axis: Union[int, Container[int]],
322        ndim: int,
323        *,
324        spaces: Spaces = Spaces(None, None),
325    ) -> Affine[ArrayT]:
326        """Create an affine reflection.
327
328        Parameters
329        ----------
330        axis
331            A single axis or multiple to reflect in.
332        ndim
333            How many dimensions to work in.
334        spaces
335            Optional source and target spaces
336
337        Returns
338        -------
339        Affine[ArrayT]
340            The reflection affine transform.
341        """
342        if isinstance(axis, (int, np.integer)):
343            axis = [axis]
344        values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)])
345        return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces)

Create an affine reflection.

Parameters
  • axis: A single axis or multiple to reflect in.
  • ndim: How many dimensions to work in.
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The reflection affine transform.
@classmethod
def rotation2( cls, rotation: float, degrees: bool = True, clockwise: bool = False, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
347    @classmethod
348    def rotation2(
349        cls,
350        rotation: float,
351        degrees: bool = True,
352        clockwise: bool = False,
353        *,
354        spaces: Spaces = Spaces(None, None),
355    ) -> Affine[ArrayT]:
356        """Create a 2D affine rotation.
357
358        Parameters
359        ----------
360        rotation
361            Angle to rotate.
362        degrees
363            Whether rotation is in degrees (rather than radians), by default True
364        clockwise
365            Whether rotation is clockwise, by default False
366        spaces
367            Optional source and target spaces
368
369        Returns
370        -------
371        Affine[ArrayT]
372            The rotation affine transform.
373        """
374        if degrees:
375            rotation = math.radians(rotation)
376        if clockwise:
377            rotation *= -1
378        c, s = math.cos(rotation), math.sin(rotation)
379        return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces)

Create a 2D affine rotation.

Parameters
  • rotation: Angle to rotate.
  • degrees: Whether rotation is in degrees (rather than radians), by default True
  • clockwise: Whether rotation is clockwise, by default False
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The rotation affine transform.
@classmethod
def rotation3( cls, rotation: Union[float, tuple[float, float, float]], degrees: bool = True, clockwise: bool = False, order: tuple[int, int, int] = (0, 1, 2), *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
381    @classmethod
382    def rotation3(
383        cls,
384        rotation: Union[float, tuple[float, float, float]],
385        degrees: bool = True,
386        clockwise: bool = False,
387        order: tuple[int, int, int] = (0, 1, 2),
388        *,
389        spaces: Spaces = Spaces(None, None),
390    ) -> Affine[ArrayT]:
391        """Create a 3D affine rotation.
392
393        Parameters
394        ----------
395        rotation
396            Either a single rotation for all axes, or 1 for each.
397        degrees
398            Whether rotation is in degrees (rather than radians), by default True
399        clockwise
400            Whether rotation is clockwise, by default False
401        order
402            What order to apply the rotations, by default (0, 1, 2)
403        spaces
404            Optional source and target spaces
405
406        Returns
407        -------
408        Affine[ArrayT]
409            The rotation affine transform.
410
411        Raises
412        ------
413        ValueError
414            Incompatible order.
415        """
416        if isinstance(rotation, (int, float)):
417            r = [rotation] * 3
418        else:
419            r = list(rotation)
420
421        if degrees:
422            r = [math.radians(x) for x in r]
423        if clockwise:
424            r = [-x for x in r]
425
426        if len(order) != 3 or set(order) != {0, 1, 2}:
427            raise ValueError("Order must contain only 0, 1, 2 in any order.")
428
429        c0, s0 = math.cos(r[0]), math.sin(r[0])
430        c1, s1 = math.cos(r[1]), math.sin(r[1])
431        c2, s2 = math.cos(r[2]), math.sin(r[2])
432
433        rots = [
434            np.array([[1, 0, 0], [0, c0, -s0], [0, s0, c0]]),
435            np.array([[c1, 0, s1], [0, 1, 0], [-s1, 0, c1]]),
436            np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]),
437        ]
438        rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]]
439        return cls.from_linear_map(rot, spaces=spaces)

Create a 3D affine rotation.

Parameters
  • rotation: Either a single rotation for all axes, or 1 for each.
  • degrees: Whether rotation is in degrees (rather than radians), by default True
  • clockwise: Whether rotation is clockwise, by default False
  • order: What order to apply the rotations, by default (0, 1, 2)
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The rotation affine transform.
Raises
  • ValueError: Incompatible order.
@classmethod
def shearing( cls, factor: Union[float, numpy.ndarray], ndim: int | None = None, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Affine[~ArrayT]:
441    @classmethod
442    def shearing(
443        cls,
444        factor: Union[float, np.ndarray],
445        ndim: int | None = None,
446        *,
447        spaces: Spaces = Spaces(None, None),
448    ) -> Affine[ArrayT]:
449        """Create an affine shear.
450
451        `factor` can be a scalar to broadcast to all dimensions,
452        or a D-length list of D-1 lists.
453        The first inner list contains the shear factors in the first dimension
454        for all *but* the first dimension.
455        The second inner list contains the shear factors in the second dimension
456        for all the *but* the second dimension, etc.
457
458        Parameters
459        ----------
460        factor
461            Shear scale factors; see above for more details.
462        ndim
463            If factor is scalar, broadcast to this many dimensions, by default None
464        spaces
465            Optional source and target spaces
466
467        Returns
468        -------
469        Affine[ArrayT]
470            The shearing affine transform.
471
472        Raises
473        ------
474        ValueError
475            Incompatible factor.
476        """
477        if isinstance(factor, (int, float, complex)):
478            if ndim is None:
479                raise ValueError("If factor is scalar, ndim must be defined")
480            s = np.full((ndim, ndim - 1), factor)
481        else:
482            s = np.asarray(factor)
483            if s.ndim != 2 or s.shape[0] != s.shape[1] + 1:
484                raise ValueError("Factor must be of shape (D, D-1)")
485            ndim = s.shape[0]
486
487        assert ndim is not None
488
489        m = np.eye(ndim, dtype=s.dtype)
490        for col_idx in range(m.shape[1]):
491            it = iter(s[col_idx])
492            for row_idx in range(m.shape[0] - 1):
493                if m[row_idx, col_idx] == 0:
494                    m[row_idx, col_idx] = next(it)
495        return cls.from_linear_map(m, spaces=spaces)

Create an affine shear.

factor can be a scalar to broadcast to all dimensions, or a D-length list of D-1 lists. The first inner list contains the shear factors in the first dimension for all but the first dimension. The second inner list contains the shear factors in the second dimension for all the but the second dimension, etc.

Parameters
  • factor: Shear scale factors; see above for more details.
  • ndim: If factor is scalar, broadcast to this many dimensions, by default None
  • spaces: Optional source and target spaces
Returns
  • Affine[ArrayT]: The shearing affine transform.
Raises
  • ValueError: Incompatible factor.
def is_identity(self) -> bool:
502    def is_identity(self) -> bool:
503        xp = array_namespace(self.matrix)
504        sh = xp.shape(self.matrix)
505        if sh[0] != sh[1]:
506            return False
507        identity = xp.eye(sh[0], dtype=self.matrix.dtype, device=self.matrix.device)
508        return xp.all(xp.equal(self.matrix, identity))

Whether this is a no-op transformation.

class Identity(transformnd.base.Transform[~ArrayT]):
20class Identity(Transform[ArrayT]):
21    """No-op transformation."""
22
23    def __init__(
24        self,
25        ndim: int,
26        *,
27        spaces: Spaces = Spaces(None, None),
28    ):
29        """
30        Transform which does nothing.
31
32        Parameters
33        ----------
34        ndim:
35            Number of dimensions of this transform.
36        spaces:
37            Optional source and target spaces
38        """
39        src = chain_or(*spaces, default=None)
40        tgt = chain_or(*spaces[::-1], default=None)
41        super().__init__(NDims(ndim, ndim), spaces=Spaces(src, tgt))
42
43    def invert(self) -> Transform[ArrayT]:
44        return type(self)(self.ndims.source, spaces=self.spaces.invert())
45
46    def to_affine(self) -> Affine[ArrayT] | None:
47        return Affine[ArrayT].identity(self.ndims.source, spaces=self.spaces)
48
49    def apply(self, coords: ArrayT) -> ArrayT:
50        return coords

No-op transformation.

Identity( ndim: int, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
23    def __init__(
24        self,
25        ndim: int,
26        *,
27        spaces: Spaces = Spaces(None, None),
28    ):
29        """
30        Transform which does nothing.
31
32        Parameters
33        ----------
34        ndim:
35            Number of dimensions of this transform.
36        spaces:
37            Optional source and target spaces
38        """
39        src = chain_or(*spaces, default=None)
40        tgt = chain_or(*spaces[::-1], default=None)
41        super().__init__(NDims(ndim, ndim), spaces=Spaces(src, tgt))

Transform which does nothing.

Parameters
  • ndim:: Number of dimensions of this transform.
  • spaces:: Optional source and target spaces
def invert(self) -> transformnd.Transform[~ArrayT]:
43    def invert(self) -> Transform[ArrayT]:
44        return type(self)(self.ndims.source, spaces=self.spaces.invert())

Invert the transformation, returning None if not possible.

def to_affine(self) -> Optional[Affine[~ArrayT]]:
46    def to_affine(self) -> Affine[ArrayT] | None:
47        return Affine[ArrayT].identity(self.ndims.source, spaces=self.spaces)

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
49    def apply(self, coords: ArrayT) -> ArrayT:
50        return coords

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
class ProjectAxis(abc.ABC, typing.Generic[ArrayT]):
 13class ProjectAxis(Transform):
 14    """Transform for adding and removing axes.
 15
 16    WARNING: inverting this transformation may be lossy.
 17    """
 18
 19    def __init__(
 20        self,
 21        dropped: set[int] | None = None,
 22        created: set[int] | None = None,
 23        source_ndim: int | None = None,
 24        target_ndim: int | None = None,
 25        *,
 26        spaces: Spaces = Spaces(None, None),
 27    ):
 28        """Create a transform for adding and dropping axes.
 29
 30        At least one of source_ndim and target_ndim must be given.
 31
 32        Parameters
 33        ----------
 34        dropped
 35            Set of INPUT dimension indices to drop, if any.
 36        created
 37            Set of OUTPUT dimension indices which are new, if any.
 38        source_ndim
 39            If omitted, can be inferred from `target_ndim`.
 40        target_ndim
 41            If omitted, can be inferred from `source_ndim`.
 42        spaces
 43            Identifiers for source and target spaces, by default Spaces(None, None)
 44
 45        Raises
 46        ------
 47        ValueError
 48            Operations are inconsistent with given dimensionality,
 49            or insufficient dimensionality information was given.
 50        """
 51        self.dropped = dropped or set()
 52        self.created = created or set()
 53
 54        if source_ndim is not None:
 55            nd = source_ndim - len(self.dropped) + len(self.created)
 56            if target_ndim is None:
 57                target_ndim = nd
 58            elif target_ndim != nd:
 59                raise ValueError("Operations do not match expected target ndim")
 60
 61        elif target_ndim is not None:
 62            nd = target_ndim - len(self.created) + len(self.dropped)
 63            if source_ndim is None:
 64                source_ndim = nd
 65            elif source_ndim != nd:
 66                raise ValueError("Operations do not match expected source ndim")
 67
 68        else:
 69            raise ValueError("At least one of source_ndim or target_ndim must be given")
 70
 71        idxs: list[int | None] = list(range(source_ndim))
 72        for drop in sorted(self.dropped, reverse=True):
 73            idxs.pop(drop)
 74        for create in sorted(self.created):
 75            idxs.insert(create, None)
 76        self._idxs = idxs
 77
 78        super().__init__(NDims(source_ndim, target_ndim), spaces=spaces)
 79
 80    def apply(self, coords: ArrayT) -> ArrayT:
 81        coords = self._validate_coords(coords)
 82        if self.created:
 83            xp = array_namespace(coords)
 84            out = xp.zeros_like(coords, shape=(xp.shape(coords)[0], self.ndims.target))
 85            for idx, orig_idx in enumerate(self._idxs):
 86                if orig_idx is not None:
 87                    out[:, idx] = coords[:, orig_idx]  # type:ignore
 88
 89        else:
 90            out = coords[:, self._idxs]  # type:ignore
 91        return out
 92
 93    def is_identity(self) -> bool:
 94        return not self.created and not self.dropped
 95
 96    def to_affine(self) -> Affine | None:
 97        m = np.eye(self.ndims.source)
 98        out_m = self.apply(m)
 99        return Affine.from_linear_map(out_m.T)
100
101    def invert(self) -> Self | None:
102        return type(self)(
103            self.created,
104            self.dropped,
105            source_ndim=self.ndims.target,
106            target_ndim=self.ndims.source,
107            spaces=self.spaces.invert(),
108        )

Transform for adding and removing axes.

WARNING: inverting this transformation may be lossy.

ProjectAxis( dropped: set[int] | None = None, created: set[int] | None = None, source_ndim: int | None = None, target_ndim: int | None = None, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
19    def __init__(
20        self,
21        dropped: set[int] | None = None,
22        created: set[int] | None = None,
23        source_ndim: int | None = None,
24        target_ndim: int | None = None,
25        *,
26        spaces: Spaces = Spaces(None, None),
27    ):
28        """Create a transform for adding and dropping axes.
29
30        At least one of source_ndim and target_ndim must be given.
31
32        Parameters
33        ----------
34        dropped
35            Set of INPUT dimension indices to drop, if any.
36        created
37            Set of OUTPUT dimension indices which are new, if any.
38        source_ndim
39            If omitted, can be inferred from `target_ndim`.
40        target_ndim
41            If omitted, can be inferred from `source_ndim`.
42        spaces
43            Identifiers for source and target spaces, by default Spaces(None, None)
44
45        Raises
46        ------
47        ValueError
48            Operations are inconsistent with given dimensionality,
49            or insufficient dimensionality information was given.
50        """
51        self.dropped = dropped or set()
52        self.created = created or set()
53
54        if source_ndim is not None:
55            nd = source_ndim - len(self.dropped) + len(self.created)
56            if target_ndim is None:
57                target_ndim = nd
58            elif target_ndim != nd:
59                raise ValueError("Operations do not match expected target ndim")
60
61        elif target_ndim is not None:
62            nd = target_ndim - len(self.created) + len(self.dropped)
63            if source_ndim is None:
64                source_ndim = nd
65            elif source_ndim != nd:
66                raise ValueError("Operations do not match expected source ndim")
67
68        else:
69            raise ValueError("At least one of source_ndim or target_ndim must be given")
70
71        idxs: list[int | None] = list(range(source_ndim))
72        for drop in sorted(self.dropped, reverse=True):
73            idxs.pop(drop)
74        for create in sorted(self.created):
75            idxs.insert(create, None)
76        self._idxs = idxs
77
78        super().__init__(NDims(source_ndim, target_ndim), spaces=spaces)

Create a transform for adding and dropping axes.

At least one of source_ndim and target_ndim must be given.

Parameters
  • dropped: Set of INPUT dimension indices to drop, if any.
  • created: Set of OUTPUT dimension indices which are new, if any.
  • source_ndim: If omitted, can be inferred from target_ndim.
  • target_ndim: If omitted, can be inferred from source_ndim.
  • spaces: Identifiers for source and target spaces, by default Spaces(None, None)
Raises
  • ValueError: Operations are inconsistent with given dimensionality, or insufficient dimensionality information was given.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
80    def apply(self, coords: ArrayT) -> ArrayT:
81        coords = self._validate_coords(coords)
82        if self.created:
83            xp = array_namespace(coords)
84            out = xp.zeros_like(coords, shape=(xp.shape(coords)[0], self.ndims.target))
85            for idx, orig_idx in enumerate(self._idxs):
86                if orig_idx is not None:
87                    out[:, idx] = coords[:, orig_idx]  # type:ignore
88
89        else:
90            out = coords[:, self._idxs]  # type:ignore
91        return out

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def is_identity(self) -> bool:
93    def is_identity(self) -> bool:
94        return not self.created and not self.dropped

Whether this is a no-op transformation.

def to_affine(self) -> Affine | None:
96    def to_affine(self) -> Affine | None:
97        m = np.eye(self.ndims.source)
98        out_m = self.apply(m)
99        return Affine.from_linear_map(out_m.T)

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def invert(self) -> Optional[Self]:
101    def invert(self) -> Self | None:
102        return type(self)(
103            self.created,
104            self.dropped,
105            source_ndim=self.ndims.target,
106            target_ndim=self.ndims.source,
107            spaces=self.spaces.invert(),
108        )

Invert the transformation, returning None if not possible.

class Reflect(transformnd.base.Transform[numpy.ndarray]):
 87class Reflect(Transform[np.ndarray]):
 88    """Reflect coordinates about arbitrary planes."""
 89
 90    def __init__(
 91        self,
 92        normals: ArrayLike,
 93        point: float | ArrayLike = 0.0,
 94        *,
 95        spaces: Spaces = Spaces(None, None),
 96    ):
 97        """
 98        Parameters
 99        ----------
100        normals
101            Normal vectors to the planes of reflection.
102            Unitised internally.
103        point
104            Intersection point of all reflection planes
105            (can be broadcast from scalar), by default 0 (i.e. the origin)
106        spaces
107            Optional source and target spaces
108
109        Raises
110        ------
111        ValueError
112            Inconsistent dimensionality
113        """
114        normals = np.asarray(normals)
115        if normals.ndim == 1:
116            normals = [normals]
117
118        n1 = normals[0]
119        if (
120            not np.isscalar(point)
121            and isinstance(point, Sequence)
122            and len(n1) != len(point)
123        ):
124            raise ValueError("Point and normals are not of the same dimensionality")
125        self.point: np.ndarray = np.asarray(point, dtype=float)
126        self.ndim = {len(n1)}
127        self.normals = [unitise(n) for n in normals]
128        # todo: matmul is associative, so turn this into an affine in 2/3D?
129        super().__init__(NDims(len(n1), len(n1)), spaces=spaces)
130
131    def apply(self, coords: np.ndarray) -> np.ndarray:
132        coords = self._validate_coords(coords)
133        out = coords - self.point
134        for n in self.normals:
135            # mul->sum vectorises dot product
136            # normals are unit, avoids unnecessary division by 1
137            out -= 2 * np.sum(coords * n, axis=1) * n
138        out += self.point
139        return out
140
141    @classmethod
142    def from_points(
143        cls,
144        points: ArrayLike,
145        *,
146        spaces: Spaces = Spaces(None, None),
147    ) -> Self:
148        """Infer a single plane of reflection from a minimal number of points on it.
149
150        Parameters
151        ----------
152        points
153            NxD array of N points in D dimensions. N == D
154        spaces
155            Optional source and target spaces
156
157        Returns
158        -------
159        Self
160        """
161        point, normals = get_hyperplanes(np.asarray(points), unitise=False)
162        return cls(normals, point, spaces=spaces)
163
164    @classmethod
165    def from_axis(
166        cls,
167        axis: int | Sequence[int],
168        origin: ArrayLike,
169        *,
170        spaces: Spaces = Spaces(None, None),
171    ) -> Self:
172        """Reflect around hyperplane(s) parallel with axes.
173
174        Parameters
175        ----------
176        axis
177            Index (or indices) of axes in which to reflect.
178        origin
179            Point around which to reflect.
180        spaces
181            Optional source and target spaces
182
183        Returns
184        -------
185        Self
186
187        Raises
188        ------
189        ValueError
190            Selected axis does not exist.
191        """
192        origin = np.asarray(origin)
193        axis = ensure_tuple(axis)
194
195        for a in axis:
196            if a >= len(axis):
197                raise ValueError(
198                    "Cannot reflect in axis which does not exist (too high)"
199                )
200
201        normals = []
202        for i in range(len(origin) - len(axis) + 1):
203            if i not in axis:
204                v = np.zeros_like(origin)
205                v[i] += 1
206                normals.append(v)
207
208        return cls(normals, origin, spaces=spaces)
209
210    def invert(self) -> Self | None:
211        return copy(self)
212
213    def to_affine(self) -> Affine[np.ndarray] | None:
214        # TODO: should be possible?
215        # rotate to align plane with origin and reflect it
216        return None

Reflect coordinates about arbitrary planes.

Reflect( normals: ArrayLike, point: float | ArrayLike = 0.0, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
 90    def __init__(
 91        self,
 92        normals: ArrayLike,
 93        point: float | ArrayLike = 0.0,
 94        *,
 95        spaces: Spaces = Spaces(None, None),
 96    ):
 97        """
 98        Parameters
 99        ----------
100        normals
101            Normal vectors to the planes of reflection.
102            Unitised internally.
103        point
104            Intersection point of all reflection planes
105            (can be broadcast from scalar), by default 0 (i.e. the origin)
106        spaces
107            Optional source and target spaces
108
109        Raises
110        ------
111        ValueError
112            Inconsistent dimensionality
113        """
114        normals = np.asarray(normals)
115        if normals.ndim == 1:
116            normals = [normals]
117
118        n1 = normals[0]
119        if (
120            not np.isscalar(point)
121            and isinstance(point, Sequence)
122            and len(n1) != len(point)
123        ):
124            raise ValueError("Point and normals are not of the same dimensionality")
125        self.point: np.ndarray = np.asarray(point, dtype=float)
126        self.ndim = {len(n1)}
127        self.normals = [unitise(n) for n in normals]
128        # todo: matmul is associative, so turn this into an affine in 2/3D?
129        super().__init__(NDims(len(n1), len(n1)), spaces=spaces)
Parameters
  • normals: Normal vectors to the planes of reflection. Unitised internally.
  • point: Intersection point of all reflection planes (can be broadcast from scalar), by default 0 (i.e. the origin)
  • spaces: Optional source and target spaces
Raises
  • ValueError: Inconsistent dimensionality
def apply(self, coords: numpy.ndarray) -> numpy.ndarray:
131    def apply(self, coords: np.ndarray) -> np.ndarray:
132        coords = self._validate_coords(coords)
133        out = coords - self.point
134        for n in self.normals:
135            # mul->sum vectorises dot product
136            # normals are unit, avoids unnecessary division by 1
137            out -= 2 * np.sum(coords * n, axis=1) * n
138        out += self.point
139        return out

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
@classmethod
def from_points( cls, points: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Self:
141    @classmethod
142    def from_points(
143        cls,
144        points: ArrayLike,
145        *,
146        spaces: Spaces = Spaces(None, None),
147    ) -> Self:
148        """Infer a single plane of reflection from a minimal number of points on it.
149
150        Parameters
151        ----------
152        points
153            NxD array of N points in D dimensions. N == D
154        spaces
155            Optional source and target spaces
156
157        Returns
158        -------
159        Self
160        """
161        point, normals = get_hyperplanes(np.asarray(points), unitise=False)
162        return cls(normals, point, spaces=spaces)

Infer a single plane of reflection from a minimal number of points on it.

Parameters
  • points: NxD array of N points in D dimensions. N == D
  • spaces: Optional source and target spaces
Returns
  • Self
@classmethod
def from_axis( cls, axis: int | Sequence[int], origin: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None)) -> Self:
164    @classmethod
165    def from_axis(
166        cls,
167        axis: int | Sequence[int],
168        origin: ArrayLike,
169        *,
170        spaces: Spaces = Spaces(None, None),
171    ) -> Self:
172        """Reflect around hyperplane(s) parallel with axes.
173
174        Parameters
175        ----------
176        axis
177            Index (or indices) of axes in which to reflect.
178        origin
179            Point around which to reflect.
180        spaces
181            Optional source and target spaces
182
183        Returns
184        -------
185        Self
186
187        Raises
188        ------
189        ValueError
190            Selected axis does not exist.
191        """
192        origin = np.asarray(origin)
193        axis = ensure_tuple(axis)
194
195        for a in axis:
196            if a >= len(axis):
197                raise ValueError(
198                    "Cannot reflect in axis which does not exist (too high)"
199                )
200
201        normals = []
202        for i in range(len(origin) - len(axis) + 1):
203            if i not in axis:
204                v = np.zeros_like(origin)
205                v[i] += 1
206                normals.append(v)
207
208        return cls(normals, origin, spaces=spaces)

Reflect around hyperplane(s) parallel with axes.

Parameters
  • axis: Index (or indices) of axes in which to reflect.
  • origin: Point around which to reflect.
  • spaces: Optional source and target spaces
Returns
  • Self
Raises
  • ValueError: Selected axis does not exist.
def invert(self) -> Optional[Self]:
210    def invert(self) -> Self | None:
211        return copy(self)

Invert the transformation, returning None if not possible.

def to_affine(self) -> Optional[Affine[numpy.ndarray]]:
213    def to_affine(self) -> Affine[np.ndarray] | None:
214        # TODO: should be possible?
215        # rotate to align plane with origin and reflect it
216        return None

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
class Scale(transformnd.base.Transform[~ArrayT]):
103class Scale(Transform[ArrayT]):
104    """Scale coordinates by multiplication."""
105
106    def __init__(
107        self,
108        scale: ArrayLike,
109        *,
110        spaces: Spaces = Spaces(None, None),
111    ):
112        """Simple scale transform.
113
114        All points are scaled, i.e. distance from the origin may also change.
115
116        Parameters
117        ----------
118        scale
119            Scaling to apply in all dimensions, or each dimension.
120        spaces
121            Optional source and target spaces
122
123        Raises
124        ------
125        ValueError
126            If scale is the wrong shape.
127        """
128        self.scale = as_floats(scale)
129        if self.scale.ndim != 1:
130            raise ValueError(f"Scale must be 1D, got shape {self.scale.shape}")
131        super().__init__(NDims(len(self.scale), len(self.scale)), spaces=spaces)
132
133    def to_affine(self) -> Affine[ArrayT] | None:
134        return Affine[ArrayT].scaling(self.scale, spaces=self.spaces)
135
136    def apply(self, coords: ArrayT) -> ArrayT:
137        coords = self._validate_coords(coords)
138        xp = array_namespace(coords)
139        d = xp_device(coords)
140        return coords * xp.asarray(self.scale, device=d)
141
142    def invert(self) -> Self | None:
143        return type(self)(
144            1 / self.scale,
145            spaces=self.spaces.invert(),
146        )
147
148    def to_device(self, xp: ModuleType, device: str | None = None) -> Self:
149        result = copy(self)
150        result.scale = xp.asarray(self.scale, device=device)
151        return result

Scale coordinates by multiplication.

Scale( scale: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
106    def __init__(
107        self,
108        scale: ArrayLike,
109        *,
110        spaces: Spaces = Spaces(None, None),
111    ):
112        """Simple scale transform.
113
114        All points are scaled, i.e. distance from the origin may also change.
115
116        Parameters
117        ----------
118        scale
119            Scaling to apply in all dimensions, or each dimension.
120        spaces
121            Optional source and target spaces
122
123        Raises
124        ------
125        ValueError
126            If scale is the wrong shape.
127        """
128        self.scale = as_floats(scale)
129        if self.scale.ndim != 1:
130            raise ValueError(f"Scale must be 1D, got shape {self.scale.shape}")
131        super().__init__(NDims(len(self.scale), len(self.scale)), spaces=spaces)

Simple scale transform.

All points are scaled, i.e. distance from the origin may also change.

Parameters
  • scale: Scaling to apply in all dimensions, or each dimension.
  • spaces: Optional source and target spaces
Raises
  • ValueError: If scale is the wrong shape.
def to_affine(self) -> Optional[Affine[~ArrayT]]:
133    def to_affine(self) -> Affine[ArrayT] | None:
134        return Affine[ArrayT].scaling(self.scale, spaces=self.spaces)

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
136    def apply(self, coords: ArrayT) -> ArrayT:
137        coords = self._validate_coords(coords)
138        xp = array_namespace(coords)
139        d = xp_device(coords)
140        return coords * xp.asarray(self.scale, device=d)

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def invert(self) -> Optional[Self]:
142    def invert(self) -> Self | None:
143        return type(self)(
144            1 / self.scale,
145            spaces=self.spaces.invert(),
146        )

Invert the transformation, returning None if not possible.

def to_device(self, xp: module, device: str | None = None) -> Self:
148    def to_device(self, xp: ModuleType, device: str | None = None) -> Self:
149        result = copy(self)
150        result.scale = xp.asarray(self.scale, device=device)
151        return result

Return a copy of this transform with array parameters placed on the given device.

Useful for pre-allocating parameters on GPU before a tight apply() loop, avoiding per-call host-to-device transfers.

Parameters
  • xp: The target array namespace (e.g. jax.numpy, torch).
  • device: Target device (e.g. from array_api_compat.device(array)). If None, uses xp's default device.
Returns
  • Self: A new transform instance with parameters on the target device, or NotImplemented if the subclass does not support device placement.
class Translate(transformnd.base.Transform[~ArrayT]):
 53class Translate(Transform[ArrayT]):
 54    """Translate coordinates by addition."""
 55
 56    def __init__(
 57        self,
 58        translation: ArrayLike,
 59        *,
 60        spaces: Spaces = Spaces(None, None),
 61    ):
 62        """Simple translation.
 63
 64        Parameters
 65        ----------
 66        translation
 67            Translation to apply in all dimensions, or each dimension.
 68        spaces
 69            Optional source and target spaces
 70
 71        Raises
 72        ------
 73        ValueError
 74            If the translation is the wrong shape
 75        """
 76        self.translation = as_floats(translation)
 77        if self.translation.ndim != 1:
 78            raise ValueError(
 79                f"Translation must be 1D, got shape {self.translation.shape}"
 80            )
 81        super().__init__(
 82            NDims(len(self.translation), len(self.translation)), spaces=spaces
 83        )
 84
 85    def to_affine(self) -> Affine[ArrayT] | None:
 86        return Affine[ArrayT].translation(self.translation, spaces=self.spaces)
 87
 88    def apply(self, coords: ArrayT) -> ArrayT:
 89        coords = self._validate_coords(coords)
 90        xp = array_namespace(coords)
 91        d = xp_device(coords)
 92        return coords + xp.asarray(self.translation, device=d)
 93
 94    def invert(self) -> Transform | None:
 95        return type(self)(-self.translation, spaces=self.spaces.invert())
 96
 97    def to_device(self, xp: ModuleType, device: str | None = None) -> Self:
 98        result = copy(self)
 99        result.translation = xp.asarray(self.translation, device=device)
100        return result

Translate coordinates by addition.

Translate( translation: ArrayLike, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
56    def __init__(
57        self,
58        translation: ArrayLike,
59        *,
60        spaces: Spaces = Spaces(None, None),
61    ):
62        """Simple translation.
63
64        Parameters
65        ----------
66        translation
67            Translation to apply in all dimensions, or each dimension.
68        spaces
69            Optional source and target spaces
70
71        Raises
72        ------
73        ValueError
74            If the translation is the wrong shape
75        """
76        self.translation = as_floats(translation)
77        if self.translation.ndim != 1:
78            raise ValueError(
79                f"Translation must be 1D, got shape {self.translation.shape}"
80            )
81        super().__init__(
82            NDims(len(self.translation), len(self.translation)), spaces=spaces
83        )

Simple translation.

Parameters
  • translation: Translation to apply in all dimensions, or each dimension.
  • spaces: Optional source and target spaces
Raises
  • ValueError: If the translation is the wrong shape
def to_affine(self) -> Optional[Affine[~ArrayT]]:
85    def to_affine(self) -> Affine[ArrayT] | None:
86        return Affine[ArrayT].translation(self.translation, spaces=self.spaces)

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
88    def apply(self, coords: ArrayT) -> ArrayT:
89        coords = self._validate_coords(coords)
90        xp = array_namespace(coords)
91        d = xp_device(coords)
92        return coords + xp.asarray(self.translation, device=d)

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def invert(self) -> transformnd.Transform | None:
94    def invert(self) -> Transform | None:
95        return type(self)(-self.translation, spaces=self.spaces.invert())

Invert the transformation, returning None if not possible.

def to_device(self, xp: module, device: str | None = None) -> Self:
 97    def to_device(self, xp: ModuleType, device: str | None = None) -> Self:
 98        result = copy(self)
 99        result.translation = xp.asarray(self.translation, device=device)
100        return result

Return a copy of this transform with array parameters placed on the given device.

Useful for pre-allocating parameters on GPU before a tight apply() loop, avoiding per-call host-to-device transfers.

Parameters
  • xp: The target array namespace (e.g. jax.numpy, torch).
  • device: Target device (e.g. from array_api_compat.device(array)). If None, uses xp's default device.
Returns
  • Self: A new transform instance with parameters on the target device, or NotImplemented if the subclass does not support device placement.
class MapAxis(transformnd.base.Transform[~ArrayT]):
12class MapAxis(Transform[ArrayT]):
13    """Map coordinates from one axis to another.
14
15    For example, x -> y and y -> x"""
16
17    def __init__(
18        self,
19        permutation: list[int],
20        *,
21        spaces: Spaces = Spaces(None, None),
22    ):
23        """Base class for transformations.
24
25        Parameters
26        ----------
27        permutation
28            New order of column axis. For example, [1, 0] means x -> y and y -> x.
29        spaces
30            Optional source and target spaces
31
32        Raises
33        ------
34        ValueError
35            If permutation does not contain all dimensions [0, N) exactly once.
36        """
37        s_perm = sorted(permutation)
38        if any(a != b for a, b in enumerate(s_perm)):
39            raise ValueError(
40                "N-D permutation must contain all dimensions [0, N) exactly once"
41            )
42        self.permutation = permutation
43        super().__init__(NDims(len(permutation), len(permutation)), spaces=spaces)
44
45    def is_identity(self) -> bool:
46        return all(a == b for a, b in enumerate(self.permutation))
47
48    def to_affine(self) -> Affine[ArrayT] | None:
49        m = np.eye(self.ndims.source)
50        m = m[self.permutation, :]
51        return Affine.from_linear_map(m, spaces=self.spaces)  # type: ignore
52
53    def apply(self, coords: ArrayT) -> ArrayT:
54        """Apply transformation to coordinates.
55
56        For example:
57        2-D with permutation [1, 0] will give you
58        [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]]
59        """
60
61        coords = self._validate_coords(coords)
62        xp = array_namespace(coords)
63        return xp.take(coords, self.permutation, 1)
64
65    def invert(self) -> Self | None:
66        return type(self)(
67            list(np.argsort(self.permutation)),
68            spaces=self.spaces.invert(),
69        )

Map coordinates from one axis to another.

For example, x -> y and y -> x

MapAxis( permutation: list[int], *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
17    def __init__(
18        self,
19        permutation: list[int],
20        *,
21        spaces: Spaces = Spaces(None, None),
22    ):
23        """Base class for transformations.
24
25        Parameters
26        ----------
27        permutation
28            New order of column axis. For example, [1, 0] means x -> y and y -> x.
29        spaces
30            Optional source and target spaces
31
32        Raises
33        ------
34        ValueError
35            If permutation does not contain all dimensions [0, N) exactly once.
36        """
37        s_perm = sorted(permutation)
38        if any(a != b for a, b in enumerate(s_perm)):
39            raise ValueError(
40                "N-D permutation must contain all dimensions [0, N) exactly once"
41            )
42        self.permutation = permutation
43        super().__init__(NDims(len(permutation), len(permutation)), spaces=spaces)

Base class for transformations.

Parameters
  • permutation: New order of column axis. For example, [1, 0] means x -> y and y -> x.
  • spaces: Optional source and target spaces
Raises
  • ValueError: If permutation does not contain all dimensions [0, N) exactly once.
def is_identity(self) -> bool:
45    def is_identity(self) -> bool:
46        return all(a == b for a, b in enumerate(self.permutation))

Whether this is a no-op transformation.

def to_affine(self) -> Optional[Affine[~ArrayT]]:
48    def to_affine(self) -> Affine[ArrayT] | None:
49        m = np.eye(self.ndims.source)
50        m = m[self.permutation, :]
51        return Affine.from_linear_map(m, spaces=self.spaces)  # type: ignore

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
53    def apply(self, coords: ArrayT) -> ArrayT:
54        """Apply transformation to coordinates.
55
56        For example:
57        2-D with permutation [1, 0] will give you
58        [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]]
59        """
60
61        coords = self._validate_coords(coords)
62        xp = array_namespace(coords)
63        return xp.take(coords, self.permutation, 1)

Apply transformation to coordinates.

For example: 2-D with permutation [1, 0] will give you [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]]

def invert(self) -> Optional[Self]:
65    def invert(self) -> Self | None:
66        return type(self)(
67            list(np.argsort(self.permutation)),
68            spaces=self.spaces.invert(),
69        )

Invert the transformation, returning None if not possible.

class Bijection(transformnd.base.Transform[~ArrayT]):
13class Bijection(Transform[ArrayT]):
14    """Map coordinates from one axis to another.
15
16    For example, x -> y and y -> x"""
17
18    def __init__(
19        self,
20        forward: Transform[ArrayT],
21        inverse: Transform[ArrayT],
22        *,
23        spaces: Spaces = Spaces(None, None),
24    ):
25        """Base class for transformations.
26
27        Parameters
28        ----------
29        forward
30            The forward transformation.
31        inverse
32            The inverse transformation.
33        spaces
34            Optional source and target spaces
35
36        Raises
37        ------
38        ValueError
39            If the forward and inverse dimensionalities don't match.
40        """
41        src = same_or_none(
42            spaces.source, forward.spaces.source, inverse.spaces.target, default=None
43        )
44        tgt = same_or_none(
45            spaces.target, forward.spaces.target, inverse.spaces.source, default=None
46        )
47
48        self.forward = forward
49        self.inverse = inverse
50        if forward.ndims != inverse.ndims.invert():
51            raise ValueError(
52                f"Bijection dimensionalities mismatch: fwd:{forward.ndims}, inv:{inverse.ndims}"
53            )
54        super().__init__(self.forward.ndims, spaces=Spaces(src, tgt))
55
56    def apply(self, coords: ArrayT) -> ArrayT:
57        return self.forward.apply(coords)
58
59    def invert(self) -> Self | None:
60        return type(self)(self.inverse, self.forward, spaces=self.spaces.invert())
61
62    def is_identity(self) -> bool:
63        return self.forward.is_identity() and self.inverse.is_identity()
64
65    def to_affine(self) -> Affine[ArrayT] | None:
66        fwd = self.forward.to_affine()
67        if fwd is None:
68            return None
69        inv = self.inverse.to_affine()
70        if inv is None:
71            return None
72
73        inv_inv = inv.invert()
74        if inv_inv is None:
75            return None
76
77        xp = array_namespace(fwd.matrix)
78        if xp.equal(fwd.matrix, inv_inv.matrix):
79            return fwd
80
81        return None

Map coordinates from one axis to another.

For example, x -> y and y -> x

Bijection( forward: transformnd.Transform[~ArrayT], inverse: transformnd.Transform[~ArrayT], *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
18    def __init__(
19        self,
20        forward: Transform[ArrayT],
21        inverse: Transform[ArrayT],
22        *,
23        spaces: Spaces = Spaces(None, None),
24    ):
25        """Base class for transformations.
26
27        Parameters
28        ----------
29        forward
30            The forward transformation.
31        inverse
32            The inverse transformation.
33        spaces
34            Optional source and target spaces
35
36        Raises
37        ------
38        ValueError
39            If the forward and inverse dimensionalities don't match.
40        """
41        src = same_or_none(
42            spaces.source, forward.spaces.source, inverse.spaces.target, default=None
43        )
44        tgt = same_or_none(
45            spaces.target, forward.spaces.target, inverse.spaces.source, default=None
46        )
47
48        self.forward = forward
49        self.inverse = inverse
50        if forward.ndims != inverse.ndims.invert():
51            raise ValueError(
52                f"Bijection dimensionalities mismatch: fwd:{forward.ndims}, inv:{inverse.ndims}"
53            )
54        super().__init__(self.forward.ndims, spaces=Spaces(src, tgt))

Base class for transformations.

Parameters
  • forward: The forward transformation.
  • inverse: The inverse transformation.
  • spaces: Optional source and target spaces
Raises
  • ValueError: If the forward and inverse dimensionalities don't match.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
56    def apply(self, coords: ArrayT) -> ArrayT:
57        return self.forward.apply(coords)

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def invert(self) -> Optional[Self]:
59    def invert(self) -> Self | None:
60        return type(self)(self.inverse, self.forward, spaces=self.spaces.invert())

Invert the transformation, returning None if not possible.

def is_identity(self) -> bool:
62    def is_identity(self) -> bool:
63        return self.forward.is_identity() and self.inverse.is_identity()

Whether this is a no-op transformation.

def to_affine(self) -> Optional[Affine[~ArrayT]]:
65    def to_affine(self) -> Affine[ArrayT] | None:
66        fwd = self.forward.to_affine()
67        if fwd is None:
68            return None
69        inv = self.inverse.to_affine()
70        if inv is None:
71            return None
72
73        inv_inv = inv.invert()
74        if inv_inv is None:
75            return None
76
77        xp = array_namespace(fwd.matrix)
78        if xp.equal(fwd.matrix, inv_inv.matrix):
79            return fwd
80
81        return None

Convert the transform into affine, if conversion is possible.

Returns
  • Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
class ByDimension(transformnd.base.Transform[~ArrayT]):
 63class ByDimension(Transform[ArrayT]):
 64    """Apply transformations to subsets of the coordinates' dimensions.
 65
 66    Adapted from: https://ngff.openmicroscopy.org/specifications/dev/index.html#bydimension
 67    """
 68
 69    def __init__(
 70        self,
 71        subtransforms: list[SubTransform[ArrayT]],
 72        fill_identity: int | None = None,
 73        *,
 74        spaces: Spaces = Spaces(None, None),
 75    ):
 76        """
 77        Parameters
 78        ----------
 79        subtransforms
 80            Transformations applying to subsets of the given coordinates.
 81        fill_identity
 82            If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions.
 83            e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`.
 84        spaces
 85            Optional source and target spaces
 86
 87        Raises
 88        ------
 89        ValueError
 90            If input or output axes are not valid.
 91        """
 92        if fill_identity is not None:
 93            to_fill_in = set(range(fill_identity))
 94            to_fill_out = set(range(fill_identity))
 95            for t in subtransforms:
 96                for i in t.input_axes:
 97                    try:
 98                        to_fill_in.remove(i)
 99                    except KeyError:
100                        pass
101                for i in t.output_axes:
102                    try:
103                        to_fill_out.remove(i)
104                    except KeyError:
105                        pass
106            subtransforms.append(
107                SubTransform(
108                    Identity(len(to_fill_in)), sorted(to_fill_in), sorted(to_fill_out)
109                )
110            )
111
112        # check that input and output axes of sub transforms are disjoint
113        sorted_in = sorted(ax for t in subtransforms for ax in t.input_axes)
114        if sorted_in != list(range(len(sorted_in))):
115            raise ValueError("N-length input axes must go from 0 to N-1")
116
117        sorted_out = sorted(ax for t in subtransforms for ax in t.output_axes)
118
119        if sorted_out != list(range(len(sorted_out))):
120            raise ValueError("N-length output axes must go from 0 to N-1")
121
122        super().__init__(NDims(len(sorted_in), len(sorted_out)), spaces=spaces)
123        self.subtransforms = subtransforms
124
125    def apply(self, coords: ArrayT) -> ArrayT:
126        """Apply transformation to subset of coordinates."""
127        coords = self._validate_coords(coords)
128        xp = array_namespace(coords)
129        output = xp.empty_like(coords)
130        for t in self.subtransforms:
131            transformed = t.transform.apply(xp.take(coords, t.input_axes, 1))
132            for idx, o in enumerate(t.output_axes):
133                output[:, o] = transformed[:, idx]  # type: ignore
134        return output
135
136    def invert(self) -> Transform[ArrayT] | None:
137        try:
138            inverted_transforms = [
139                SubTransform[ArrayT](
140                    input_axes=t.output_axes,
141                    output_axes=t.input_axes,
142                    transform=~t.transform,
143                )
144                for t in reversed(self.subtransforms)
145            ]
146        except NotImplementedError:
147            return None
148
149        return type(self)(
150            subtransforms=inverted_transforms,
151            spaces=self.spaces.invert(),
152        )
153
154    def is_identity(self) -> bool:
155        for t in self.subtransforms:
156            if t.input_axes != t.output_axes or not t.transform.is_identity():
157                return False
158        return True

Apply transformations to subsets of the coordinates' dimensions.

Adapted from: https://ngff.openmicroscopy.org/specifications/dev/index.html#bydimension

ByDimension( subtransforms: list[SubTransform[~ArrayT]], fill_identity: int | None = None, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
 69    def __init__(
 70        self,
 71        subtransforms: list[SubTransform[ArrayT]],
 72        fill_identity: int | None = None,
 73        *,
 74        spaces: Spaces = Spaces(None, None),
 75    ):
 76        """
 77        Parameters
 78        ----------
 79        subtransforms
 80            Transformations applying to subsets of the given coordinates.
 81        fill_identity
 82            If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions.
 83            e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`.
 84        spaces
 85            Optional source and target spaces
 86
 87        Raises
 88        ------
 89        ValueError
 90            If input or output axes are not valid.
 91        """
 92        if fill_identity is not None:
 93            to_fill_in = set(range(fill_identity))
 94            to_fill_out = set(range(fill_identity))
 95            for t in subtransforms:
 96                for i in t.input_axes:
 97                    try:
 98                        to_fill_in.remove(i)
 99                    except KeyError:
100                        pass
101                for i in t.output_axes:
102                    try:
103                        to_fill_out.remove(i)
104                    except KeyError:
105                        pass
106            subtransforms.append(
107                SubTransform(
108                    Identity(len(to_fill_in)), sorted(to_fill_in), sorted(to_fill_out)
109                )
110            )
111
112        # check that input and output axes of sub transforms are disjoint
113        sorted_in = sorted(ax for t in subtransforms for ax in t.input_axes)
114        if sorted_in != list(range(len(sorted_in))):
115            raise ValueError("N-length input axes must go from 0 to N-1")
116
117        sorted_out = sorted(ax for t in subtransforms for ax in t.output_axes)
118
119        if sorted_out != list(range(len(sorted_out))):
120            raise ValueError("N-length output axes must go from 0 to N-1")
121
122        super().__init__(NDims(len(sorted_in), len(sorted_out)), spaces=spaces)
123        self.subtransforms = subtransforms
Parameters
  • subtransforms: Transformations applying to subsets of the given coordinates.
  • fill_identity: If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions. e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and fill_identity=3.
  • spaces: Optional source and target spaces
Raises
  • ValueError: If input or output axes are not valid.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
125    def apply(self, coords: ArrayT) -> ArrayT:
126        """Apply transformation to subset of coordinates."""
127        coords = self._validate_coords(coords)
128        xp = array_namespace(coords)
129        output = xp.empty_like(coords)
130        for t in self.subtransforms:
131            transformed = t.transform.apply(xp.take(coords, t.input_axes, 1))
132            for idx, o in enumerate(t.output_axes):
133                output[:, o] = transformed[:, idx]  # type: ignore
134        return output

Apply transformation to subset of coordinates.

def invert(self) -> Optional[transformnd.Transform[~ArrayT]]:
136    def invert(self) -> Transform[ArrayT] | None:
137        try:
138            inverted_transforms = [
139                SubTransform[ArrayT](
140                    input_axes=t.output_axes,
141                    output_axes=t.input_axes,
142                    transform=~t.transform,
143                )
144                for t in reversed(self.subtransforms)
145            ]
146        except NotImplementedError:
147            return None
148
149        return type(self)(
150            subtransforms=inverted_transforms,
151            spaces=self.spaces.invert(),
152        )

Invert the transformation, returning None if not possible.

def is_identity(self) -> bool:
154    def is_identity(self) -> bool:
155        for t in self.subtransforms:
156            if t.input_axes != t.output_axes or not t.transform.is_identity():
157                return False
158        return True

Whether this is a no-op transformation.

class SubTransform(typing.Generic[ArrayT]):
11class SubTransform[ArrayT]:
12    """Component of the `ByDimension` transformation.
13
14    Transformation to apply to subsets of the input dimensions and which output dimensions they calculate.
15    """
16
17    def __init__(
18        self,
19        transform: Transform[ArrayT],
20        input_axes: list[int],
21        output_axes: list[int] | None = None,
22    ):
23        """
24        Parameters
25        ----------
26        transform
27            Transformation to apply to the subset of axes.
28        input_axes
29            Which axes to apply the transformation to, in order.
30            The length must match the input dimensionality of `transform`.
31        output_axes
32            Which axes to apply the transformation to, in order.
33            The length must match the input dimensionality of `transform`.
34            If None, re-use the input axes.
35
36        Raises
37        ------
38        ValueError
39            `transform`'s dimensionality does not match the input/output axes.
40        """
41
42        self.input_axes = input_axes
43        if output_axes is None:
44            self.output_axes = input_axes
45        else:
46            self.output_axes = output_axes
47
48        in_ndim = len(self.input_axes)
49        out_ndim = len(self.output_axes)
50
51        if transform.ndims.source != in_ndim:
52            raise ValueError(
53                f"Subtransform input dimensionality ({transform.ndims.source}) must match length of input_axes"
54            )
55        if transform.ndims.target != out_ndim:
56            raise ValueError(
57                f"Subtransform output dimensionality ({transform.ndims.target}) must match length of output_axes"
58            )
59
60        self.transform = transform

Component of the ByDimension transformation.

Transformation to apply to subsets of the input dimensions and which output dimensions they calculate.

SubTransform( transform: transformnd.Transform[ArrayT], input_axes: list[int], output_axes: list[int] | None = None)
17    def __init__(
18        self,
19        transform: Transform[ArrayT],
20        input_axes: list[int],
21        output_axes: list[int] | None = None,
22    ):
23        """
24        Parameters
25        ----------
26        transform
27            Transformation to apply to the subset of axes.
28        input_axes
29            Which axes to apply the transformation to, in order.
30            The length must match the input dimensionality of `transform`.
31        output_axes
32            Which axes to apply the transformation to, in order.
33            The length must match the input dimensionality of `transform`.
34            If None, re-use the input axes.
35
36        Raises
37        ------
38        ValueError
39            `transform`'s dimensionality does not match the input/output axes.
40        """
41
42        self.input_axes = input_axes
43        if output_axes is None:
44            self.output_axes = input_axes
45        else:
46            self.output_axes = output_axes
47
48        in_ndim = len(self.input_axes)
49        out_ndim = len(self.output_axes)
50
51        if transform.ndims.source != in_ndim:
52            raise ValueError(
53                f"Subtransform input dimensionality ({transform.ndims.source}) must match length of input_axes"
54            )
55        if transform.ndims.target != out_ndim:
56            raise ValueError(
57                f"Subtransform output dimensionality ({transform.ndims.target}) must match length of output_axes"
58            )
59
60        self.transform = transform
Parameters
  • transform: Transformation to apply to the subset of axes.
  • input_axes: Which axes to apply the transformation to, in order. The length must match the input dimensionality of transform.
  • output_axes: Which axes to apply the transformation to, in order. The length must match the input dimensionality of transform. If None, re-use the input axes.
Raises
  • ValueError: transform's dimensionality does not match the input/output axes.
class Coordinates(transformnd.transforms.vector_field.BaseVectorField[~ArrayT]):
130class Coordinates(BaseVectorField[ArrayT]):
131    """Look up the output coordinates in an array.
132
133    For input coordinate `(a, b, c)` and `vector_axis=-1`,
134    the output coordinate is `vector_field[a, b, c, :].
135
136    Input coordinates outside the vector field return NaN.
137
138    REQUIRES: `vectorfield` extra for in-memory,
139    or `vectorfield-dask` extra for lazy chunked vector fields.
140    """
141
142    def __init__(
143        self,
144        vector_field: ArrayT,
145        index_transform: Transform[ArrayT] | None = None,
146        interpolation_order: int = 3,
147        vector_axis: int = -1,
148        *,
149        spaces: Spaces = Spaces(None, None),
150    ):
151        """Use the input coordinates as array indices to look up output coordinates.
152
153        For input coordinate `(a, b, c)`, the output coordinate is `coordinates[a, b, c, :]`.
154
155        Input coordinates outside of the `vector_field` array return `NaN` output coordinates.
156
157        Parameters
158        ----------
159        vector_field
160            Array with `Di + 1` dimensions, where `Di` is the input dimensionality.
161        index_transform
162            Transform the source coordinates into an array index
163        interpolation_order
164            Order of the spline interpolation used for coordinates which are not integer array indices.
165        vector_axis
166            Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`).
167        spaces
168            References for source and target spaces
169        """
170        super().__init__(
171            vector_field,
172            index_transform,
173            interpolation_order,
174            vector_axis,
175            spaces=spaces,
176        )
177
178    def apply(self, coords: ArrayT) -> ArrayT:
179        return self._get_vectors(coords)

Look up the output coordinates in an array.

For input coordinate (a, b, c) and vector_axis=-1, the output coordinate is `vector_field[a, b, c, :].

Input coordinates outside the vector field return NaN.

REQUIRES: vectorfield extra for in-memory, or vectorfield-dask extra for lazy chunked vector fields.

Coordinates( vector_field: ~ArrayT, index_transform: Optional[transformnd.Transform[~ArrayT]] = None, interpolation_order: int = 3, vector_axis: int = -1, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
142    def __init__(
143        self,
144        vector_field: ArrayT,
145        index_transform: Transform[ArrayT] | None = None,
146        interpolation_order: int = 3,
147        vector_axis: int = -1,
148        *,
149        spaces: Spaces = Spaces(None, None),
150    ):
151        """Use the input coordinates as array indices to look up output coordinates.
152
153        For input coordinate `(a, b, c)`, the output coordinate is `coordinates[a, b, c, :]`.
154
155        Input coordinates outside of the `vector_field` array return `NaN` output coordinates.
156
157        Parameters
158        ----------
159        vector_field
160            Array with `Di + 1` dimensions, where `Di` is the input dimensionality.
161        index_transform
162            Transform the source coordinates into an array index
163        interpolation_order
164            Order of the spline interpolation used for coordinates which are not integer array indices.
165        vector_axis
166            Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`).
167        spaces
168            References for source and target spaces
169        """
170        super().__init__(
171            vector_field,
172            index_transform,
173            interpolation_order,
174            vector_axis,
175            spaces=spaces,
176        )

Use the input coordinates as array indices to look up output coordinates.

For input coordinate (a, b, c), the output coordinate is coordinates[a, b, c, :].

Input coordinates outside of the vector_field array return NaN output coordinates.

Parameters
  • vector_field: Array with Di + 1 dimensions, where Di is the input dimensionality.
  • index_transform: Transform the source coordinates into an array index
  • interpolation_order: Order of the spline interpolation used for coordinates which are not integer array indices.
  • vector_axis: Which axis of the vector_field contains the vector values; defaults to the last (-1).
  • spaces: References for source and target spaces
def apply(self, coords: ~ArrayT) -> ~ArrayT:
178    def apply(self, coords: ArrayT) -> ArrayT:
179        return self._get_vectors(coords)

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
class Displacements(transformnd.transforms.vector_field.BaseVectorField[~ArrayT]):
182class Displacements(BaseVectorField[ArrayT]):
183    """Look up a translation in an array and apply it to the input coordinates.
184
185    For input coordinate `(a, b, c)` and `vector_axis=-1`,
186    the output coordinate is `(a, b, c) + vector_field[a, b, c, :].
187
188    Input coordinates outside the vector field return NaN.
189
190    REQUIRES: `vectorfield` extra for in-memory,
191    or `vectorfield-dask` extra for lazy chunked vector fields.
192    """
193
194    def __init__(
195        self,
196        vector_field: ArrayT,
197        index_transform: Transform[ArrayT] | None = None,
198        interpolation_order: int = 3,
199        vector_axis: int = -1,
200        *,
201        spaces: Spaces = Spaces(None, None),
202    ):
203        """
204        Parameters
205        ----------
206        vector_field
207            Array with `Di + 1` dimensions, where `Di` is the input dimensionality.
208        index_transform
209            Transformation from source coordinate to array indices.
210        interpolation_order
211            Order of the spline interpolation used for coordinates which are not integer array indices.
212        vector_axis
213            Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`).
214        spaces
215            References for source and target spaces
216
217        Raises
218        ------
219        ValueError
220            If the index transform and vector field would change the coordinates' dimensionality,
221            or the index transform's dimensionality does not match the vector field's.
222        """
223        super().__init__(
224            vector_field,
225            index_transform,
226            interpolation_order,
227            vector_axis,
228            spaces=spaces,
229        )
230        if self.ndims.source != self.ndims.target:
231            raise ValueError("Displacements cannot change dimensionality")
232
233    def apply(self, coords: ArrayT) -> ArrayT:
234        coords = self._validate_coords(coords)
235        vecs = self._get_vectors(coords)
236        return coords + vecs  # type:ignore

Look up a translation in an array and apply it to the input coordinates.

For input coordinate (a, b, c) and vector_axis=-1, the output coordinate is `(a, b, c) + vector_field[a, b, c, :].

Input coordinates outside the vector field return NaN.

REQUIRES: vectorfield extra for in-memory, or vectorfield-dask extra for lazy chunked vector fields.

Displacements( vector_field: ~ArrayT, index_transform: Optional[transformnd.Transform[~ArrayT]] = None, interpolation_order: int = 3, vector_axis: int = -1, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
194    def __init__(
195        self,
196        vector_field: ArrayT,
197        index_transform: Transform[ArrayT] | None = None,
198        interpolation_order: int = 3,
199        vector_axis: int = -1,
200        *,
201        spaces: Spaces = Spaces(None, None),
202    ):
203        """
204        Parameters
205        ----------
206        vector_field
207            Array with `Di + 1` dimensions, where `Di` is the input dimensionality.
208        index_transform
209            Transformation from source coordinate to array indices.
210        interpolation_order
211            Order of the spline interpolation used for coordinates which are not integer array indices.
212        vector_axis
213            Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`).
214        spaces
215            References for source and target spaces
216
217        Raises
218        ------
219        ValueError
220            If the index transform and vector field would change the coordinates' dimensionality,
221            or the index transform's dimensionality does not match the vector field's.
222        """
223        super().__init__(
224            vector_field,
225            index_transform,
226            interpolation_order,
227            vector_axis,
228            spaces=spaces,
229        )
230        if self.ndims.source != self.ndims.target:
231            raise ValueError("Displacements cannot change dimensionality")
Parameters
  • vector_field: Array with Di + 1 dimensions, where Di is the input dimensionality.
  • index_transform: Transformation from source coordinate to array indices.
  • interpolation_order: Order of the spline interpolation used for coordinates which are not integer array indices.
  • vector_axis: Which axis of the vector_field contains the vector values; defaults to the last (-1).
  • spaces: References for source and target spaces
Raises
  • ValueError: If the index transform and vector field would change the coordinates' dimensionality, or the index transform's dimensionality does not match the vector field's.
def apply(self, coords: ~ArrayT) -> ~ArrayT:
233    def apply(self, coords: ArrayT) -> ArrayT:
234        coords = self._validate_coords(coords)
235        vecs = self._get_vectors(coords)
236        return coords + vecs  # type:ignore

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
class MovingLeastSquares(transformnd.base.Transform[numpy.ndarray]):
17class MovingLeastSquares(Transform[np.ndarray]):
18    """Moving least squares transformation.
19
20    Deform based on a matched pairs of source and target control points; see <https://dl.acm.org/doi/10.1145/1141911.1141920>
21
22    REQUIRES: `movingleastsquares` extra.
23    """
24
25    def __init__(
26        self,
27        source_control_points: np.ndarray,
28        target_control_points: np.ndarray,
29        *,
30        spaces: Spaces = Spaces(None, None),
31    ):
32        """Non-rigid transforms powered by molesq package.
33
34        Parameters
35        ----------
36        source_control_points
37            NxD array of control point coordinates in the source space.
38        target_control_points
39            NxD array of coordinates of the corresponding control points
40            in the target (deformed) space.
41        spaces
42            Optional source and target spaces
43        """
44        from molesq.transform import Transformer
45
46        s = as_floats(source_control_points)
47        t = as_floats(target_control_points)
48        self._transformer = Transformer(s, t)
49        super().__init__(
50            NDims(
51                s.shape[1],
52                t.shape[1],
53            ),
54            spaces=spaces,
55        )
56
57    def apply(self, coords: np.ndarray) -> np.ndarray:
58        coords = self._validate_coords(coords)
59        return self._transformer.transform(coords)
60
61    def is_identity(self) -> bool:
62        xp = array_namespace(self._transformer.control_points)
63        return xp.all(
64            xp.equal(
65                self._transformer.control_points,
66                self._transformer.deformed_control_points,
67            )
68        )
69
70    def invert(self) -> Self | None:
71        return type(self)(
72            self._transformer.deformed_control_points,
73            self._transformer.control_points,
74            spaces=self.spaces.invert(),
75        )

Moving least squares transformation.

Deform based on a matched pairs of source and target control points; see https://dl.acm.org/doi/10.1145/1141911.1141920

REQUIRES: movingleastsquares extra.

MovingLeastSquares( source_control_points: numpy.ndarray, target_control_points: numpy.ndarray, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
25    def __init__(
26        self,
27        source_control_points: np.ndarray,
28        target_control_points: np.ndarray,
29        *,
30        spaces: Spaces = Spaces(None, None),
31    ):
32        """Non-rigid transforms powered by molesq package.
33
34        Parameters
35        ----------
36        source_control_points
37            NxD array of control point coordinates in the source space.
38        target_control_points
39            NxD array of coordinates of the corresponding control points
40            in the target (deformed) space.
41        spaces
42            Optional source and target spaces
43        """
44        from molesq.transform import Transformer
45
46        s = as_floats(source_control_points)
47        t = as_floats(target_control_points)
48        self._transformer = Transformer(s, t)
49        super().__init__(
50            NDims(
51                s.shape[1],
52                t.shape[1],
53            ),
54            spaces=spaces,
55        )

Non-rigid transforms powered by molesq package.

Parameters
  • source_control_points: NxD array of control point coordinates in the source space.
  • target_control_points: NxD array of coordinates of the corresponding control points in the target (deformed) space.
  • spaces: Optional source and target spaces
def apply(self, coords: numpy.ndarray) -> numpy.ndarray:
57    def apply(self, coords: np.ndarray) -> np.ndarray:
58        coords = self._validate_coords(coords)
59        return self._transformer.transform(coords)

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.
def is_identity(self) -> bool:
61    def is_identity(self) -> bool:
62        xp = array_namespace(self._transformer.control_points)
63        return xp.all(
64            xp.equal(
65                self._transformer.control_points,
66                self._transformer.deformed_control_points,
67            )
68        )

Whether this is a no-op transformation.

def invert(self) -> Optional[Self]:
70    def invert(self) -> Self | None:
71        return type(self)(
72            self._transformer.deformed_control_points,
73            self._transformer.control_points,
74            spaces=self.spaces.invert(),
75        )

Invert the transformation, returning None if not possible.

class ThinPlateSplines(transformnd.base.Transform[numpy.ndarray]):
19class ThinPlateSplines(Transform[np.ndarray]):
20    """Thin plate splines transforms.
21
22    Deform based on matched pairs of control points.
23
24    REQUIRES: `thinplatesplines` extra.
25    """
26
27    def __init__(
28        self,
29        source_control_points: np.ndarray,
30        target_control_points: np.ndarray,
31        *,
32        spaces: Spaces = Spaces(None, None),
33    ):
34        """Non-rigid control point based transforms in 2/3D.
35
36        Adapted from
37        https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py
38
39        Parameters
40        ----------
41        source_control_points
42            NxD array of control point coordinates in the source space.
43        target_control_points
44            NxD array of control point coordinates in the target (deformed) space.
45        spaces
46            Optional source and target spaces
47
48        Raises
49        ------
50        ValueError
51            Invalid control points.
52        """
53        import morphops
54
55        self.source_control_points = as_floats(source_control_points)
56        self.target_control_points = as_floats(target_control_points)
57
58        if self.source_control_points.shape != self.target_control_points.shape:
59            raise ValueError("Control point arrays must be the same shape")
60
61        if self.source_control_points.ndim != 2:
62            raise ValueError("Control points array must be 2D")
63
64        ndim = self.source_control_points.shape[1]
65
66        self.W, self.A = morphops.tps_coefs(
67            self.source_control_points,
68            self.target_control_points,
69        )
70        super().__init__(NDims(ndim, ndim), spaces=spaces)
71
72    def invert(self) -> Transform[np.ndarray] | None:
73        return type(self)(
74            self.target_control_points,
75            self.source_control_points,
76            spaces=self.spaces.invert(),
77        )
78
79    def apply(self, coords: np.ndarray) -> np.ndarray:
80        import morphops
81
82        coords = self._validate_coords(coords)
83        U = morphops.K_matrix(coords, self.source_control_points)
84        P = morphops.P_matrix(coords)
85        # The warped pts are the affine part + the non-uniform part
86        return P @ self.A + U @ self.W

Thin plate splines transforms.

Deform based on matched pairs of control points.

REQUIRES: thinplatesplines extra.

ThinPlateSplines( source_control_points: numpy.ndarray, target_control_points: numpy.ndarray, *, spaces: transformnd.Spaces = Spaces(source=None, target=None))
27    def __init__(
28        self,
29        source_control_points: np.ndarray,
30        target_control_points: np.ndarray,
31        *,
32        spaces: Spaces = Spaces(None, None),
33    ):
34        """Non-rigid control point based transforms in 2/3D.
35
36        Adapted from
37        https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py
38
39        Parameters
40        ----------
41        source_control_points
42            NxD array of control point coordinates in the source space.
43        target_control_points
44            NxD array of control point coordinates in the target (deformed) space.
45        spaces
46            Optional source and target spaces
47
48        Raises
49        ------
50        ValueError
51            Invalid control points.
52        """
53        import morphops
54
55        self.source_control_points = as_floats(source_control_points)
56        self.target_control_points = as_floats(target_control_points)
57
58        if self.source_control_points.shape != self.target_control_points.shape:
59            raise ValueError("Control point arrays must be the same shape")
60
61        if self.source_control_points.ndim != 2:
62            raise ValueError("Control points array must be 2D")
63
64        ndim = self.source_control_points.shape[1]
65
66        self.W, self.A = morphops.tps_coefs(
67            self.source_control_points,
68            self.target_control_points,
69        )
70        super().__init__(NDims(ndim, ndim), spaces=spaces)

Non-rigid control point based transforms in 2/3D.

Adapted from https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py

Parameters
  • source_control_points: NxD array of control point coordinates in the source space.
  • target_control_points: NxD array of control point coordinates in the target (deformed) space.
  • spaces: Optional source and target spaces
Raises
  • ValueError: Invalid control points.
def invert(self) -> Optional[transformnd.Transform[numpy.ndarray]]:
72    def invert(self) -> Transform[np.ndarray] | None:
73        return type(self)(
74            self.target_control_points,
75            self.source_control_points,
76            spaces=self.spaces.invert(),
77        )

Invert the transformation, returning None if not possible.

def apply(self, coords: numpy.ndarray) -> numpy.ndarray:
79    def apply(self, coords: np.ndarray) -> np.ndarray:
80        import morphops
81
82        coords = self._validate_coords(coords)
83        U = morphops.K_matrix(coords, self.source_control_points)
84        P = morphops.P_matrix(coords)
85        # The warped pts are the affine part + the non-uniform part
86        return P @ self.A + U @ self.W

Apply transformation.

Parameters
  • coords: NxD array of N D-dimensional coordinates.
Returns
  • ArrayT: Transformed coordinates in the same shape.