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.
- transformnd/__init__.py +24 -0
- transformnd/adapters/__init__.py +34 -0
- transformnd/adapters/base.py +167 -0
- transformnd/adapters/bounding_box.py +16 -0
- transformnd/adapters/pandas.py +46 -0
- transformnd/adapters/polars.py +44 -0
- transformnd/adapters/shapely.py +124 -0
- transformnd/base.py +441 -0
- transformnd/extents/__init__.py +0 -0
- transformnd/extents/base.py +10 -0
- transformnd/extents/bounding_box.py +36 -0
- transformnd/graph.py +192 -0
- transformnd/py.typed +0 -0
- transformnd/transforms/__init__.py +19 -0
- transformnd/transforms/affine.py +512 -0
- transformnd/transforms/bijection.py +68 -0
- transformnd/transforms/by_dimension.py +133 -0
- transformnd/transforms/map_axis.py +67 -0
- transformnd/transforms/moving_least_squares.py +66 -0
- transformnd/transforms/reflection.py +207 -0
- transformnd/transforms/simple.py +172 -0
- transformnd/transforms/thinplate.py +83 -0
- transformnd/util.py +251 -0
- transformnd-0.1.0.dist-info/METADATA +133 -0
- transformnd-0.1.0.dist-info/RECORD +26 -0
- transformnd-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|