transformnd 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,512 @@
1
+ """
2
+ Rigid transformations implemented as affine multiplications.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+ from typing import Container, Union, Self
9
+
10
+ import numpy as np
11
+ from numpy.typing import ArrayLike
12
+
13
+ from copy import copy
14
+
15
+ from array_api_compat import array_namespace, device as xp_device
16
+ from ..base import Transform, ArrayT
17
+ from ..util import is_square, none_eq, SpaceTuple, to_single_ndim
18
+
19
+
20
+ def arg_as_array(arg, ndim: int | None) -> np.ndarray:
21
+ """Convert a scalar or array-like argument to a 1-D NumPy array.
22
+
23
+ Parameters
24
+ ----------
25
+ arg :
26
+ Scalar (broadcast to ndim) or array-like.
27
+ ndim : int, optional
28
+ Required length. If arg is scalar, used to broadcast.
29
+ """
30
+ if isinstance(arg, (int, float, complex)):
31
+ if ndim is None:
32
+ raise ValueError("Argument must be array-like or ndim must be given")
33
+ return np.full(ndim, arg)
34
+ arr = np.asarray(arg)
35
+ if ndim is not None and len(arr) != ndim:
36
+ raise ValueError("Mismatch between ndim and length of argument")
37
+ return arr
38
+
39
+
40
+ class Affine(Transform[ArrayT]):
41
+ """Affine transformation using an augmented matrix.
42
+
43
+ The transformation matrix is stored as a NumPy array (backend-neutral).
44
+ At apply()-time it is converted to the input coords' backend and device,
45
+ so the transform works transparently with NumPy, JAX, PyTorch, CuPy, etc.
46
+ """
47
+
48
+ matrix: np.ndarray
49
+
50
+ def __init__(
51
+ self,
52
+ matrix: ArrayLike,
53
+ *,
54
+ spaces: SpaceTuple = (None, None),
55
+ ):
56
+ """
57
+ Affine transformation matrices' bottom row must be all zeroes except a 1 in the rightmost column.
58
+
59
+ Matrix may have shape:
60
+
61
+ - (D+1, D+1): an affine transformation matrix (the bottom row will be validated)
62
+ - (D, D+1): an affine transformation matrix missing the bottom row (it will be added)
63
+ - (D,): a vector of scales which become the first D elements of a diagonal matrix of size D+1
64
+
65
+ Parameters
66
+ ----------
67
+ matrix : ArrayLike
68
+ Affine transformation matrix. Any array-like (including JAX/PyTorch
69
+ arrays) is accepted and converted to NumPy for storage.
70
+ spaces : tuple[SpaceRef, SpaceRef]
71
+ Optional source and target spaces
72
+
73
+ Raises
74
+ ------
75
+ ValueError
76
+ Malformed matrix.
77
+ """
78
+ super().__init__(spaces=spaces)
79
+ m = np.asarray(matrix)
80
+
81
+ if m.ndim == 1:
82
+ scales = m
83
+ m = np.eye(len(m) + 1, dtype=m.dtype)
84
+ m[:-1, :-1] *= scales
85
+ elif m.ndim != 2:
86
+ raise ValueError("Transformation matrix must be 2D")
87
+
88
+ if m.shape[1] == m.shape[0] + 1:
89
+ base = np.eye(m.shape[1], dtype=m.dtype)
90
+ base[:-1, :] = m
91
+ m = base
92
+ elif not is_square(m):
93
+ raise ValueError("Transformation matrix must be square")
94
+ else:
95
+ bottom_row = m[-1, :]
96
+ if bottom_row[-1] != 1 or not np.all(bottom_row[:-1] == 0):
97
+ raise ValueError(
98
+ "Transformation matrix is not affine (bottom row must be [0,0,0,...,1])."
99
+ )
100
+
101
+ self.matrix = m
102
+
103
+ self._linear_map: np.ndarray | None = m[:-1, :-1]
104
+ if np.allclose(
105
+ self._linear_map, np.eye(self._linear_map.shape[0], dtype=self.matrix.dtype)
106
+ ):
107
+ self._linear_map = None
108
+
109
+ self._translation: np.ndarray | None = self.matrix[:-1, -1]
110
+ if np.allclose(np.zeros_like(self._translation), self._translation):
111
+ self._translation = None
112
+
113
+ self.ndim = {len(self.matrix) - 1}
114
+
115
+ def to_affine(self, ndim: int | None = None) -> Self | None:
116
+ # check that if ndim is given, it matches expectation
117
+ to_single_ndim(ndim, self.ndim)
118
+ return self
119
+
120
+ def cast_matrix(self, namespace, device) -> ArrayT:
121
+ return namespace.asarray(self.matrix, device=device)
122
+
123
+ def apply(self, coords: ArrayT) -> ArrayT:
124
+ coords = self._validate_coords(coords)
125
+ xp = array_namespace(coords)
126
+ d = xp_device(coords)
127
+
128
+ out = coords
129
+
130
+ if self._linear_map is not None:
131
+ lm = xp.asarray(self._linear_map, device=d)
132
+ out = coords @ xp.matrix_transpose(lm)
133
+
134
+ if self._translation is not None:
135
+ t = xp.asarray(self._translation, device=d)
136
+ if self._linear_map is None:
137
+ out = coords + t
138
+ else:
139
+ out += t
140
+
141
+ ## Padding and then unpadding the coords is slower, especially in C order
142
+ # coords = xp.concatenate(
143
+ # [coords, xp.ones((coords.shape[0], 1), dtype=coords.dtype)], # type: ignore[attr-defined]
144
+ # axis=1,
145
+ # )
146
+ # out: ArrayT = (coords @ m.T)[:, :-1] # type: ignore[attr-defined]
147
+
148
+ return out
149
+
150
+ def invert(self) -> Self | None:
151
+ try:
152
+ inv = np.linalg.inv(self.matrix)
153
+ except np.linalg.LinAlgError:
154
+ return None
155
+
156
+ return type(self)(
157
+ inv,
158
+ spaces=(self.spaces[1], self.spaces[0]),
159
+ )
160
+
161
+ def __matmul__(self, rhs: Affine[ArrayT]) -> Affine[ArrayT]:
162
+ """Compose two affine transforms by matrix multiplication.
163
+
164
+ As with affine matrices the right hand operand is effectively applied first.
165
+
166
+ Parameters
167
+ ----------
168
+ rhs : AffineTransform
169
+
170
+ Returns
171
+ -------
172
+ AffineTransform
173
+
174
+ Raises
175
+ ------
176
+ ValueError
177
+ Incompatible transforms.
178
+ """
179
+ if not isinstance(rhs, Affine):
180
+ return NotImplemented
181
+ if self.matrix.shape != rhs.matrix.shape:
182
+ raise ValueError(
183
+ "Cannot multiply affine matrices of different dimensionality"
184
+ )
185
+ if not none_eq(self.target_space, rhs.source_space):
186
+ raise ValueError("Affine transforms do not share a space")
187
+ return Affine(
188
+ self.matrix @ rhs.matrix,
189
+ spaces=(self.source_space, rhs.target_space),
190
+ )
191
+
192
+ def to_device(self, xp, device=None) -> "Affine[ArrayT]":
193
+ """Return a copy with the matrix placed on the given device/backend.
194
+
195
+ Use this before a tight apply() loop to avoid per-call host-to-device
196
+ transfers when coords live on GPU.
197
+
198
+ Parameters
199
+ ----------
200
+ xp : array namespace
201
+ Target array namespace (e.g. jax.numpy, torch).
202
+ device : device object, optional
203
+ Target device (e.g. from array_api_compat.device(array)).
204
+
205
+ Returns
206
+ -------
207
+ Affine
208
+ New instance with matrix on the target device.
209
+ """
210
+ result = copy(self)
211
+ result.matrix = xp.asarray(self.matrix, device=device)
212
+ return result
213
+
214
+ @classmethod
215
+ def from_linear_map(
216
+ cls,
217
+ linear_map: ArrayLike,
218
+ translation=0,
219
+ *,
220
+ spaces: SpaceTuple = (None, None),
221
+ ) -> Affine[ArrayT]:
222
+ """Create an augmented affine matrix from a linear map,
223
+ with an optional translation.
224
+
225
+ Parameters
226
+ ----------
227
+ linear_map : ArrayLike
228
+ Shape (D, D)
229
+ translation : ArrayLike, optional
230
+ Translation to add to the matrix, by default 0
231
+ spaces : tuple[SpaceRef, SpaceRef]
232
+ Optional source and target spaces
233
+
234
+ Returns
235
+ -------
236
+ AffineTransform
237
+ """
238
+ lin_map = np.asarray(linear_map)
239
+ side = len(lin_map) + 1
240
+ matrix = np.eye(side, dtype=lin_map.dtype)
241
+ matrix[:-1, :-1] = lin_map
242
+ matrix[:-1, -1] = translation
243
+ return cls(matrix, spaces=spaces)
244
+
245
+ @classmethod
246
+ def identity(
247
+ cls,
248
+ ndim: int,
249
+ *,
250
+ spaces: SpaceTuple = (None, None),
251
+ ) -> Affine[ArrayT]:
252
+ """Create an identity affine transformation.
253
+
254
+ Parameters
255
+ ----------
256
+ ndim : int
257
+ spaces : tuple[SpaceRef, SpaceRef]
258
+ Optional source and target spaces
259
+
260
+ Returns
261
+ -------
262
+ AffineTransform
263
+ """
264
+ return cls(np.eye(ndim + 1), spaces=spaces)
265
+
266
+ @classmethod
267
+ def translation(
268
+ cls,
269
+ translation: ArrayLike,
270
+ ndim: int | None = None,
271
+ *,
272
+ spaces: SpaceTuple = (None, None),
273
+ ) -> Affine[ArrayT]:
274
+ """Create an affine translation.
275
+
276
+ Parameters
277
+ ----------
278
+ translation : ArrayLike
279
+ If scalar, broadcast to ndim.
280
+ ndim : int, optional
281
+ If translation is scalar, how many dims to use.
282
+ spaces : tuple[SpaceRef, SpaceRef]
283
+ Optional source and target spaces
284
+
285
+ Returns
286
+ -------
287
+ AffineTransform
288
+ """
289
+ t = arg_as_array(translation, ndim)
290
+ m = np.eye(len(t) + 1, dtype=t.dtype)
291
+ m[:-1, -1] = t
292
+ return cls(m, spaces=spaces)
293
+
294
+ @classmethod
295
+ def scaling(
296
+ cls,
297
+ scale: ArrayLike,
298
+ ndim: int | None = None,
299
+ *,
300
+ spaces: SpaceTuple = (None, None),
301
+ ) -> Affine[ArrayT]:
302
+ """Create an affine scaling.
303
+
304
+ Parameters
305
+ ----------
306
+ scale : ArrayLike
307
+ If scalar, broadcast to ndim.
308
+ ndim : Optional[int], optional
309
+ If scale is scalar, how many dimensions to use
310
+ spaces : tuple[SpaceRef, SpaceRef]
311
+ Optional source and target spaces
312
+
313
+ Returns
314
+ -------
315
+ AffineTransform
316
+ """
317
+ s = arg_as_array(scale, ndim)
318
+ m = np.eye(len(s) + 1, dtype=s.dtype)
319
+ m[:-1, :-1] *= s
320
+ return cls(m, spaces=spaces)
321
+
322
+ @classmethod
323
+ def reflection(
324
+ cls,
325
+ axis: Union[int, Container[int]],
326
+ ndim: int,
327
+ *,
328
+ spaces: SpaceTuple = (None, None),
329
+ ) -> Affine[ArrayT]:
330
+ """Create an affine reflection.
331
+
332
+ Parameters
333
+ ----------
334
+ axis : Union[int, Container[int]]
335
+ A single axis or multiple to reflect in.
336
+ ndim : int
337
+ How many dimensions to work in.
338
+ spaces : tuple[SpaceRef, SpaceRef]
339
+ Optional source and target spaces
340
+
341
+ Returns
342
+ -------
343
+ AffineTransform
344
+ """
345
+ if isinstance(axis, (int, np.integer)):
346
+ axis = [axis]
347
+ values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)])
348
+ return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces)
349
+
350
+ @classmethod
351
+ def rotation2(
352
+ cls,
353
+ rotation: float,
354
+ degrees=True,
355
+ clockwise=False,
356
+ *,
357
+ spaces: SpaceTuple = (None, None),
358
+ ) -> Affine[ArrayT]:
359
+ """Create a 2D affine rotation.
360
+
361
+ Parameters
362
+ ----------
363
+ rotation : float
364
+ Angle to rotate.
365
+ degrees : bool, optional
366
+ Whether rotation is in degrees (rather than radians), by default True
367
+ clockwise : bool, optional
368
+ Whether rotation is clockwise, by default False
369
+ spaces : tuple[SpaceRef, SpaceRef]
370
+ Optional source and target spaces
371
+
372
+ Returns
373
+ -------
374
+ AffineTransform
375
+ """
376
+ if degrees:
377
+ rotation = math.radians(rotation)
378
+ if clockwise:
379
+ rotation *= -1
380
+ c, s = math.cos(rotation), math.sin(rotation)
381
+ return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces)
382
+
383
+ @classmethod
384
+ def rotation3(
385
+ cls,
386
+ rotation: Union[float, tuple[float, float, float]],
387
+ degrees=True,
388
+ clockwise=False,
389
+ order=(0, 1, 2),
390
+ *,
391
+ spaces: SpaceTuple = (None, None),
392
+ ) -> Affine[ArrayT]:
393
+ """Create a 3D affine rotation.
394
+
395
+ Parameters
396
+ ----------
397
+ rotation : Union[float, Tuple[float, float, float]]
398
+ Either a single rotation for all axes, or 1 for each.
399
+ degrees : bool, optional
400
+ Whether rotation is in degrees (rather than radians), by default True
401
+ clockwise : bool, optional
402
+ Whether rotation is clockwise, by default False
403
+ order : tuple, optional
404
+ What order to apply the rotations, by default (0, 1, 2)
405
+ spaces : tuple[SpaceRef, SpaceRef]
406
+ Optional source and target spaces
407
+
408
+ Returns
409
+ -------
410
+ AffineTransform
411
+
412
+ Raises
413
+ ------
414
+ ValueError
415
+ Incompatible order.
416
+ """
417
+ if isinstance(rotation, (int, float)):
418
+ r = [rotation] * 3
419
+ else:
420
+ r = list(rotation)
421
+
422
+ if degrees:
423
+ r = [math.radians(x) for x in r]
424
+ if clockwise:
425
+ r = [-x for x in r]
426
+
427
+ if len(order) != 3 or set(order) != {0, 1, 2}:
428
+ raise ValueError("Order must contain only 0, 1, 2 in any order.")
429
+
430
+ order = list(order)
431
+ c0, s0 = math.cos(r[0]), math.sin(r[0])
432
+ c1, s1 = math.cos(r[1]), math.sin(r[1])
433
+ c2, s2 = math.cos(r[2]), math.sin(r[2])
434
+
435
+ rots = [
436
+ np.array([[1, 0, 0], [0, c0, -s0], [0, s0, c0]]),
437
+ np.array([[c1, 0, s1], [0, 1, 0], [-s1, 0, c1]]),
438
+ np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]),
439
+ ]
440
+ rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]]
441
+ return cls.from_linear_map(rot, spaces=spaces)
442
+
443
+ @classmethod
444
+ def shearing(
445
+ cls,
446
+ factor: Union[float, np.ndarray],
447
+ ndim: int | None = None,
448
+ *,
449
+ spaces: SpaceTuple = (None, None),
450
+ ) -> Affine[ArrayT]:
451
+ """Create an affine shear.
452
+
453
+ `factor` can be a scalar to broadcast to all dimensions,
454
+ or a D-length list of D-1 lists.
455
+ The first inner list contains the shear factors in the first dimension
456
+ for all *but* the first dimension.
457
+ The second inner list contains the shear factors in the second dimension
458
+ for all the *but* the second dimension, etc.
459
+
460
+ Parameters
461
+ ----------
462
+ factor : Union[float, np.ndarray]
463
+ Shear scale factors; see above for more details.
464
+ ndim : Optional[int], optional
465
+ If factor is scalar, broadcast to this many dimensions, by default None
466
+ spaces : tuple[SpaceRef, SpaceRef]
467
+ Optional source and target spaces
468
+
469
+ Returns
470
+ -------
471
+ AffineTransform
472
+
473
+ Raises
474
+ ------
475
+ ValueError
476
+ Incompatible factor.
477
+ """
478
+ if isinstance(factor, (int, float, complex)):
479
+ if ndim is None:
480
+ raise ValueError("If factor is scalar, ndim must be defined")
481
+ s = np.full((ndim, ndim - 1), factor)
482
+ else:
483
+ s = np.asarray(factor)
484
+ if s.ndim != 2 or s.shape[0] != s.shape[1] + 1:
485
+ raise ValueError("Factor must be of shape (D, D-1)")
486
+ ndim = s.shape[0]
487
+
488
+ assert ndim is not None
489
+
490
+ m = np.eye(ndim, dtype=s.dtype)
491
+ for col_idx in range(m.shape[1]):
492
+ it = iter(s[col_idx])
493
+ for row_idx in range(m.shape[0] - 1):
494
+ if m[row_idx, col_idx] == 0:
495
+ m[row_idx, col_idx] = next(it)
496
+ return cls.from_linear_map(m, spaces=spaces)
497
+
498
+ def __eq__(self, other: object) -> bool:
499
+ if not isinstance(other, Affine):
500
+ return NotImplemented
501
+ return np.array_equal(self.matrix, other.matrix) and self.spaces == other.spaces
502
+
503
+ def into_affine(self, ndim: int | None = None) -> Affine[ArrayT]:
504
+ ndim = to_single_ndim(ndim, self.ndim)
505
+ return self
506
+
507
+ def is_identity(self) -> bool:
508
+ xp = array_namespace(self.matrix)
509
+ assert self.ndim is not None
510
+ ndim = list(self.ndim).pop()
511
+ identity = xp.eye(ndim + 1, dtype=self.matrix.dtype, device=self.matrix.device)
512
+ return xp.all(xp.equal(self.matrix, identity))
@@ -0,0 +1,68 @@
1
+ from typing import Self
2
+
3
+ from array_api_compat import array_namespace
4
+
5
+ from transformnd.transforms.affine import Affine
6
+
7
+ from ..base import Transform, ArrayT
8
+ from ..util import SpaceTuple, dim_intersection, invert_spaces
9
+
10
+
11
+ class Bijection(Transform[ArrayT]):
12
+ """Map coordinates from one axis to another.
13
+
14
+ For example, x -> y and y -> x"""
15
+
16
+ # ndim: Optional[Set[int]] = set(2)
17
+
18
+ def __init__(
19
+ self,
20
+ forward: Transform[ArrayT],
21
+ inverse: Transform[ArrayT],
22
+ *,
23
+ spaces: SpaceTuple = (None, None),
24
+ ):
25
+ """Base class for transformations.
26
+
27
+ Parameters
28
+ ----------
29
+ spaces : tuple[SpaceRef, SpaceRef]
30
+ Optional source and target spaces
31
+ """
32
+
33
+ self.forward = forward
34
+ self.inverse = inverse
35
+ ndim = dim_intersection(forward.ndim, inverse.ndim)
36
+ if ndim is not None and len(ndim) == 0:
37
+ raise ValueError(
38
+ "forward and inverse transforms do not share a dimensionality"
39
+ )
40
+ self.ndim = ndim
41
+ self.spaces = spaces
42
+
43
+ def apply(self, coords: ArrayT) -> ArrayT:
44
+ return self.forward.apply(coords)
45
+
46
+ def invert(self) -> Self | None:
47
+ return type(self)(self.inverse, self.forward, spaces=invert_spaces(self.spaces))
48
+
49
+ def is_identity(self) -> bool:
50
+ return self.forward.is_identity() and self.inverse.is_identity()
51
+
52
+ def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
53
+ fwd = self.forward.to_affine(ndim)
54
+ if fwd is None:
55
+ return None
56
+ inv = self.inverse.to_affine(ndim)
57
+ if inv is None:
58
+ return None
59
+
60
+ inv_inv = inv.invert()
61
+ if inv_inv is None:
62
+ return None
63
+
64
+ xp = array_namespace(fwd.matrix)
65
+ if xp.equal(fwd.matrix, inv_inv.matrix):
66
+ return fwd
67
+
68
+ return None
@@ -0,0 +1,133 @@
1
+ from array_api_compat import array_namespace
2
+
3
+ from .simple import Identity
4
+
5
+ from ..base import Transform
6
+ from ..util import SpaceTuple, ArrayT, check_ndim, invert_spaces
7
+
8
+
9
+ class SubTransform[ArrayT]:
10
+ """Transformation to apply to subsets of the input dimensions and which output dimensions they calculate."""
11
+
12
+ def __init__(
13
+ self,
14
+ transform: Transform[ArrayT],
15
+ input_axes: list[int],
16
+ output_axes: list[int] | None = None,
17
+ ):
18
+
19
+ self.input_axes = input_axes
20
+ if output_axes is None:
21
+ # this needs to be adjusted if we want to support drop and add axis
22
+ self.output_axes = input_axes
23
+ else:
24
+ self.output_axes = output_axes
25
+
26
+ in_ndim = len(self.input_axes)
27
+ out_ndim = len(self.output_axes)
28
+
29
+ if len(set(self.input_axes)) != in_ndim:
30
+ raise ValueError("Input axes must be unique and non-empty")
31
+ if len(set(self.output_axes)) != out_ndim:
32
+ raise ValueError("Output axes must be unique and non-empty")
33
+
34
+ if in_ndim != out_ndim:
35
+ raise ValueError("Input and output axes must have the same length")
36
+
37
+ check_ndim(in_ndim, transform.ndim)
38
+
39
+ self.ndim = in_ndim
40
+ self.transform = transform
41
+
42
+
43
+ class ByDimension(Transform[ArrayT]):
44
+ """Apply transformations to subsets of the coordinates' dimensions.
45
+
46
+ Adapted from: https://ngff.openmicroscopy.org/specifications/dev/index.html#bydimension
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ subtransforms: list[SubTransform[ArrayT]],
52
+ fill_identity: int | None = None,
53
+ *,
54
+ spaces: SpaceTuple = (None, None),
55
+ ):
56
+ """
57
+ Parameters
58
+ ----------
59
+ subtransforms: list[Subtransform]
60
+ Transformations applying to subsets of the given coordinates.
61
+ fill_identity: int | None
62
+ If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions.
63
+ e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`.
64
+ spaces : tuple[SpaceRef, SpaceRef]
65
+ Optional source and target spaces
66
+ """
67
+ self.spaces = spaces
68
+
69
+ if fill_identity is not None:
70
+ to_fill_in = set(range(fill_identity))
71
+ to_fill_out = set(range(fill_identity))
72
+ for t in subtransforms:
73
+ for i in t.input_axes:
74
+ try:
75
+ to_fill_in.remove(i)
76
+ except KeyError:
77
+ pass
78
+ for i in t.output_axes:
79
+ try:
80
+ to_fill_out.remove(i)
81
+ except KeyError:
82
+ pass
83
+ subtransforms.append(
84
+ SubTransform(Identity(), sorted(to_fill_in), sorted(to_fill_out))
85
+ )
86
+
87
+ # check that input and output axes of sub transforms are disjoint
88
+ sorted_in = sorted(ax for t in subtransforms for ax in t.input_axes)
89
+ if sorted_in != list(range(len(sorted_in))):
90
+ raise ValueError("N-length input axes must go from 0 to N-1")
91
+
92
+ sorted_out = sorted(ax for t in subtransforms for ax in t.output_axes)
93
+
94
+ if sorted_out != list(range(len(sorted_out))):
95
+ raise ValueError("N-length output axes must go from 0 to N-1")
96
+
97
+ self.subtransforms = subtransforms
98
+ self.ndim = {len(sorted_in)}
99
+
100
+ def apply(self, coords: ArrayT) -> ArrayT:
101
+ """Apply transformation to subset of coordinates."""
102
+ coords = self._validate_coords(coords)
103
+ xp = array_namespace(coords)
104
+ output = xp.empty_like(coords)
105
+ for t in self.subtransforms:
106
+ transformed = t.transform.apply(xp.take(coords, t.input_axes, 1))
107
+ for idx, o in enumerate(t.output_axes):
108
+ output[:, o] = transformed[:, idx] # type: ignore
109
+ return output
110
+
111
+ def invert(self) -> Transform[ArrayT] | None:
112
+ try:
113
+ inverted_transforms = [
114
+ SubTransform[ArrayT](
115
+ input_axes=t.output_axes,
116
+ output_axes=t.input_axes,
117
+ transform=~t.transform,
118
+ )
119
+ for t in reversed(self.subtransforms)
120
+ ]
121
+ except NotImplementedError:
122
+ return None
123
+
124
+ return type(self)(
125
+ subtransforms=inverted_transforms,
126
+ spaces=invert_spaces(self.spaces),
127
+ )
128
+
129
+ def is_identity(self) -> bool:
130
+ for t in self.subtransforms:
131
+ if t.input_axes != t.output_axes or not t.transform.is_identity():
132
+ return False
133
+ return True