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
transformnd/base.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Base classes and wrappers for transforms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from copy import copy
|
|
8
|
+
from typing import Self, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from array_api_compat import array_namespace
|
|
11
|
+
|
|
12
|
+
from .util import (
|
|
13
|
+
SpaceRef,
|
|
14
|
+
TransformSignature,
|
|
15
|
+
check_ndim,
|
|
16
|
+
dim_intersection,
|
|
17
|
+
invert_spaces,
|
|
18
|
+
same_or_none,
|
|
19
|
+
space_str,
|
|
20
|
+
to_single_ndim,
|
|
21
|
+
window,
|
|
22
|
+
SpaceTuple,
|
|
23
|
+
ArrayT,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .transforms import Affine
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transform[ArrayT](ABC):
|
|
31
|
+
"""Base class for transforms."""
|
|
32
|
+
|
|
33
|
+
ndim: set[int] | None = None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
spaces: SpaceTuple = (None, None),
|
|
39
|
+
):
|
|
40
|
+
"""
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
44
|
+
Optional source and target spaces
|
|
45
|
+
"""
|
|
46
|
+
self.spaces = spaces
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def source_space(self):
|
|
50
|
+
return self.spaces[0]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def target_space(self):
|
|
54
|
+
return self.spaces[1]
|
|
55
|
+
|
|
56
|
+
def is_identity(self) -> bool:
|
|
57
|
+
"""Whether this is a no-op transformation."""
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
|
|
61
|
+
"""Convert the transform into affine, if conversion is possible.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
dim: int, optional
|
|
66
|
+
Total number of dimensions; If None, dim is set equal to self.ndim.
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
Transform | None:
|
|
71
|
+
The affine transformation, if conversion is possible.
|
|
72
|
+
None otherwise.
|
|
73
|
+
"""
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def _validate_coords(self, coords: ArrayT) -> ArrayT:
|
|
77
|
+
"""Check that dimension of coords are supported.
|
|
78
|
+
|
|
79
|
+
Also ensure that coords is a 2D array.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
coords : ArrayT
|
|
84
|
+
NxD array of N D-dimensional coordinates.
|
|
85
|
+
|
|
86
|
+
Raises
|
|
87
|
+
------
|
|
88
|
+
ValueError
|
|
89
|
+
If dimensions are not supported.
|
|
90
|
+
"""
|
|
91
|
+
xp = array_namespace(coords)
|
|
92
|
+
if xp.ndim(coords) != 2:
|
|
93
|
+
raise ValueError("Coords must be a 2D array")
|
|
94
|
+
check_ndim(xp.shape(coords)[1], self.ndim)
|
|
95
|
+
return coords
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
99
|
+
"""Apply transformation.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
coords : ArrayT
|
|
104
|
+
NxD array of N D-dimensional coordinates.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
np.ndarray
|
|
109
|
+
Transformed coordinates in the same shape.
|
|
110
|
+
"""
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
def invert(self) -> Transform | None:
|
|
114
|
+
"""Invert the transformation, returning `None` if not possible."""
|
|
115
|
+
if self.is_identity():
|
|
116
|
+
return copy(self)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def __invert__(self) -> Transform:
|
|
120
|
+
"""Invert transformation if possible.
|
|
121
|
+
|
|
122
|
+
Returns `NotImplemented` otherwise (will raise `NotImplementedError`).
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
Transform
|
|
127
|
+
Inverted transformation.
|
|
128
|
+
"""
|
|
129
|
+
t = self.invert()
|
|
130
|
+
if t is None:
|
|
131
|
+
return NotImplemented
|
|
132
|
+
return t
|
|
133
|
+
|
|
134
|
+
def to_device(self, xp, device=None) -> Self: # noqa: ARG002
|
|
135
|
+
"""Return a copy of this transform with array parameters placed on the given device.
|
|
136
|
+
|
|
137
|
+
Useful for pre-allocating parameters on GPU before a tight apply() loop,
|
|
138
|
+
avoiding per-call host-to-device transfers.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
xp : array namespace
|
|
143
|
+
The target array namespace (e.g. jax.numpy, torch).
|
|
144
|
+
device : device object, optional
|
|
145
|
+
Target device (e.g. from array_api_compat.device(array)).
|
|
146
|
+
If None, uses xp's default device.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
Transform
|
|
151
|
+
A new transform instance with parameters on the target device,
|
|
152
|
+
or NotImplemented if the subclass does not support device placement.
|
|
153
|
+
"""
|
|
154
|
+
return NotImplemented
|
|
155
|
+
|
|
156
|
+
def __or__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]:
|
|
157
|
+
"""Compose transformations into a sequence.
|
|
158
|
+
|
|
159
|
+
If other is a TransformSequence, prepend this transform to the others.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
other : Transform
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
TransformSequence
|
|
168
|
+
"""
|
|
169
|
+
if not isinstance(other, Transform):
|
|
170
|
+
return NotImplemented
|
|
171
|
+
transforms = get_transform_list(self) + get_transform_list(other)
|
|
172
|
+
return TransformSequence[ArrayT](
|
|
173
|
+
transforms,
|
|
174
|
+
spaces=(self.source_space, other.target_space),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]:
|
|
178
|
+
"""Compose transformations into a sequence.
|
|
179
|
+
|
|
180
|
+
If other is a TransformSequence, append this transform to the others.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
other : Transform
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
TransformSequence
|
|
189
|
+
"""
|
|
190
|
+
if not isinstance(other, Transform):
|
|
191
|
+
return NotImplemented
|
|
192
|
+
transforms = get_transform_list(other) + get_transform_list(self)
|
|
193
|
+
return TransformSequence(
|
|
194
|
+
transforms,
|
|
195
|
+
spaces=(other.source_space, self.target_space),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def __str__(self) -> str:
|
|
199
|
+
cls_name = type(self).__name__
|
|
200
|
+
src = space_str(self.source_space)
|
|
201
|
+
tgt = space_str(self.target_space)
|
|
202
|
+
return f"{cls_name}[{src}->{tgt}]"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class TransformWrapper(Transform[ArrayT]):
|
|
206
|
+
"""Wrapper around an arbitrary function which transforms coordinates."""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
fn: TransformSignature[ArrayT],
|
|
211
|
+
ndim: set[int] | int | None = None,
|
|
212
|
+
*,
|
|
213
|
+
spaces: SpaceTuple = (None, None),
|
|
214
|
+
):
|
|
215
|
+
"""Wrapper around an arbitrary function.
|
|
216
|
+
|
|
217
|
+
`fn` should take and return an identically-shaped
|
|
218
|
+
NxD numpy array of N D-dimensional coordinates.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
fn : TransformSignature
|
|
223
|
+
Callable.
|
|
224
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
225
|
+
Optional source and target spaces
|
|
226
|
+
"""
|
|
227
|
+
super().__init__(spaces=spaces)
|
|
228
|
+
self.fn = fn
|
|
229
|
+
if ndim is not None:
|
|
230
|
+
if isinstance(ndim, int):
|
|
231
|
+
self.ndim = {ndim}
|
|
232
|
+
else:
|
|
233
|
+
self.ndim = set(ndim)
|
|
234
|
+
|
|
235
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
236
|
+
self._validate_coords(coords)
|
|
237
|
+
return self.fn(coords)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _with_spaces(
|
|
241
|
+
t: Transform[ArrayT],
|
|
242
|
+
source_space: SpaceRef | None = None,
|
|
243
|
+
target_space: SpaceRef | None = None,
|
|
244
|
+
) -> Transform[ArrayT]:
|
|
245
|
+
src_tgt = (t.source_space, t.target_space)
|
|
246
|
+
src = same_or_none(src_tgt[0], source_space, default=None)
|
|
247
|
+
tgt = same_or_none(src_tgt[1], target_space, default=None)
|
|
248
|
+
if (src, tgt) != src_tgt:
|
|
249
|
+
t = copy(t)
|
|
250
|
+
t.spaces = (src, tgt)
|
|
251
|
+
return t
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def infer_spaces(
|
|
255
|
+
transforms: Sequence[Transform[ArrayT]], source_space=None, target_space=None
|
|
256
|
+
) -> list[Transform[ArrayT]]:
|
|
257
|
+
prev_tgts = [source_space]
|
|
258
|
+
next_srcs = []
|
|
259
|
+
for t1, t2 in window(transforms, 2):
|
|
260
|
+
prev_tgts.append(t1.target_space)
|
|
261
|
+
next_srcs.append(t2.source_space)
|
|
262
|
+
|
|
263
|
+
next_srcs.append(target_space)
|
|
264
|
+
|
|
265
|
+
out = []
|
|
266
|
+
for t, next_src, prev_tgt in zip(transforms, next_srcs, prev_tgts):
|
|
267
|
+
out.append(_with_spaces(t, prev_tgt, next_src))
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]:
|
|
272
|
+
if isinstance(t, TransformSequence):
|
|
273
|
+
return t.transforms.copy()
|
|
274
|
+
else:
|
|
275
|
+
return [t]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
|
|
279
|
+
"""Chain transforms, applying one after another."""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
transforms: Sequence[Transform[ArrayT]],
|
|
284
|
+
*,
|
|
285
|
+
spaces: SpaceTuple = (None, None),
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Combine transforms by chaining them.
|
|
288
|
+
|
|
289
|
+
Also checks for consistent dimensionality and space references,
|
|
290
|
+
inferring if None.
|
|
291
|
+
|
|
292
|
+
Parameters
|
|
293
|
+
----------
|
|
294
|
+
transforms : List[Transform[ArrayT]]
|
|
295
|
+
Items which are a TransformSequences
|
|
296
|
+
will each still be treated as a single transform.
|
|
297
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
298
|
+
Optional source and target spaces.
|
|
299
|
+
Can also be inferred from the first and last transforms.
|
|
300
|
+
|
|
301
|
+
Raises
|
|
302
|
+
------
|
|
303
|
+
ValueError
|
|
304
|
+
If spaces are incompatible.
|
|
305
|
+
"""
|
|
306
|
+
ts = infer_spaces(transforms, *spaces)
|
|
307
|
+
|
|
308
|
+
super().__init__(
|
|
309
|
+
spaces=(
|
|
310
|
+
ts[0].source_space,
|
|
311
|
+
ts[-1].target_space,
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
self.transforms: list[Transform[ArrayT]] = ts
|
|
316
|
+
|
|
317
|
+
self.ndim = None
|
|
318
|
+
for t in self.transforms:
|
|
319
|
+
self.ndim = dim_intersection(self.ndim, t.ndim)
|
|
320
|
+
|
|
321
|
+
if self.ndim is not None and len(self.ndim) == 0:
|
|
322
|
+
raise ValueError("Transforms have incompatible dimensionalities")
|
|
323
|
+
|
|
324
|
+
def __iter__(self) -> Iterator[Transform[ArrayT]]:
|
|
325
|
+
"""Iterate through component transforms.
|
|
326
|
+
|
|
327
|
+
Yields
|
|
328
|
+
-------
|
|
329
|
+
Transform
|
|
330
|
+
"""
|
|
331
|
+
yield from self.transforms
|
|
332
|
+
|
|
333
|
+
def __len__(self) -> int:
|
|
334
|
+
"""Number of transforms.
|
|
335
|
+
|
|
336
|
+
Returns
|
|
337
|
+
-------
|
|
338
|
+
int
|
|
339
|
+
"""
|
|
340
|
+
return len(self.transforms)
|
|
341
|
+
|
|
342
|
+
def invert(self) -> Transform[ArrayT] | None:
|
|
343
|
+
try:
|
|
344
|
+
transforms = [~t for t in reversed(self.transforms)]
|
|
345
|
+
except NotImplementedError:
|
|
346
|
+
return None
|
|
347
|
+
return type(self)(
|
|
348
|
+
transforms,
|
|
349
|
+
spaces=invert_spaces(self.spaces),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
353
|
+
for t in self.transforms:
|
|
354
|
+
coords = t.apply(coords)
|
|
355
|
+
return coords
|
|
356
|
+
|
|
357
|
+
def to_device(self, xp, device=None) -> Self:
|
|
358
|
+
result = copy(self)
|
|
359
|
+
result.transforms = [t.to_device(xp, device) for t in self.transforms]
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
def list_spaces(self, skip_none=False) -> list[SpaceRef]:
|
|
363
|
+
"""List spaces in this transform.
|
|
364
|
+
|
|
365
|
+
Parameters
|
|
366
|
+
----------
|
|
367
|
+
skip_none : bool, optional
|
|
368
|
+
Whether to skip undefined spaces, default False.
|
|
369
|
+
|
|
370
|
+
Returns
|
|
371
|
+
-------
|
|
372
|
+
List[SpaceRef]
|
|
373
|
+
"""
|
|
374
|
+
spaces = [self.source_space] + [t.target_space for t in self.transforms]
|
|
375
|
+
if skip_none:
|
|
376
|
+
spaces = [s for s in spaces if s is not None]
|
|
377
|
+
return spaces
|
|
378
|
+
|
|
379
|
+
def __str__(self) -> str:
|
|
380
|
+
cls_name = type(self).__name__
|
|
381
|
+
spaces_str = "->".join(space_str(s) for s in self.list_spaces())
|
|
382
|
+
return f"{cls_name}[{spaces_str}]"
|
|
383
|
+
|
|
384
|
+
def __getitem__(self, idx: slice | int):
|
|
385
|
+
if isinstance(idx, int):
|
|
386
|
+
return self.transforms[idx]
|
|
387
|
+
return type(self)(self.transforms[idx])
|
|
388
|
+
|
|
389
|
+
def is_identity(self) -> bool:
|
|
390
|
+
return all(t.is_identity() for t in self)
|
|
391
|
+
|
|
392
|
+
def simplify(self, ndim: int | None = None, drop_inverse: bool = False):
|
|
393
|
+
"""Reduce the number of transformations in this sequence if possible.
|
|
394
|
+
|
|
395
|
+
- Compose consecutive transformations which can be expressed as affines
|
|
396
|
+
- Drop trivial transforms (e.g. identity)
|
|
397
|
+
- Optionally drop explicit inverse transforms
|
|
398
|
+
(e.g. replace `Bijection`s with their `forward` transform)
|
|
399
|
+
|
|
400
|
+
Also drops all internal space tuples; only the sequence's remains.
|
|
401
|
+
|
|
402
|
+
Does not check whether transforms invert each other,
|
|
403
|
+
e.g. `Translation(1) | Translation(-1)`.
|
|
404
|
+
"""
|
|
405
|
+
from .transforms.bijection import Bijection
|
|
406
|
+
|
|
407
|
+
ndim = to_single_ndim(ndim, self.ndim)
|
|
408
|
+
out: list[Transform[ArrayT]] = []
|
|
409
|
+
affine = None
|
|
410
|
+
for t in self.transforms:
|
|
411
|
+
if drop_inverse and isinstance(t, Bijection):
|
|
412
|
+
t = t.forward
|
|
413
|
+
|
|
414
|
+
new_affine = t.to_affine(ndim)
|
|
415
|
+
|
|
416
|
+
if new_affine is None:
|
|
417
|
+
if affine is not None:
|
|
418
|
+
add_to_output(affine, out)
|
|
419
|
+
affine = None
|
|
420
|
+
add_to_output(t, out)
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
if affine is None:
|
|
424
|
+
affine = new_affine
|
|
425
|
+
else:
|
|
426
|
+
affine = new_affine @ affine # type: ignore[operator]
|
|
427
|
+
|
|
428
|
+
if affine is not None:
|
|
429
|
+
add_to_output(affine, out)
|
|
430
|
+
|
|
431
|
+
return type(self)(out)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def add_to_output(transform: Transform, lst: list[Transform]) -> bool:
|
|
435
|
+
if transform.is_identity():
|
|
436
|
+
return False
|
|
437
|
+
|
|
438
|
+
transform = copy(transform)
|
|
439
|
+
transform.spaces = (None, None)
|
|
440
|
+
lst.append(transform)
|
|
441
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from array_api_compat import array_namespace
|
|
3
|
+
from ..util import ArrayT, are_coords
|
|
4
|
+
from .base import Extents
|
|
5
|
+
from array_api_compat import device as xp_device
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BoundingBox(Extents[ArrayT]):
|
|
9
|
+
def __init__(self, mins: ArrayT, maxes: ArrayT) -> None:
|
|
10
|
+
xp = array_namespace(mins)
|
|
11
|
+
if xp.shape(mins) != xp.shape(maxes):
|
|
12
|
+
raise ValueError("mins and maxes must be the same shape")
|
|
13
|
+
if len(xp.shape(mins)) != 1:
|
|
14
|
+
raise ValueError("mins and maxes must be 1D")
|
|
15
|
+
|
|
16
|
+
self.ndim = {xp.shape(mins)[0]}
|
|
17
|
+
self.mins = mins
|
|
18
|
+
self.maxes = maxes
|
|
19
|
+
|
|
20
|
+
@lru_cache()
|
|
21
|
+
def extents_cast(self, namespace, device) -> tuple[ArrayT, ArrayT]:
|
|
22
|
+
return (
|
|
23
|
+
namespace.asarray(self.mins, device=device),
|
|
24
|
+
namespace.asarray(self.maxes, device=device),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def _validate_coords(self, coords: ArrayT) -> ArrayT:
|
|
28
|
+
return are_coords(coords, self.ndim)
|
|
29
|
+
|
|
30
|
+
def contains(self, coords: ArrayT) -> ArrayT:
|
|
31
|
+
coords = self._validate_coords(coords)
|
|
32
|
+
xp = array_namespace(coords)
|
|
33
|
+
device = xp_device(coords)
|
|
34
|
+
mins, maxes = self.extents_cast(xp, device)
|
|
35
|
+
|
|
36
|
+
return xp.logical_and(xp.greater_equal(coords, mins), xp.less(coords, maxes))
|
transformnd/graph.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Bridging transforms between known spaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from collections.abc import Iterable, Iterator
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from .base import Transform, TransformSequence
|
|
10
|
+
from .util import SpaceRef, chain_or, dim_intersection, window, ArrayT
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def split_sequence(seq: TransformSequence[ArrayT]) -> Iterator[Transform[ArrayT]]:
|
|
14
|
+
"""Split a TransformSequence into Transforms with spaces defined.
|
|
15
|
+
|
|
16
|
+
If a component Transform has its spaces defined,
|
|
17
|
+
it will be yielded as-is.
|
|
18
|
+
A chain of Transforms without spaces defined are yielded as a TransformSequence.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
seq : TransformSequence
|
|
23
|
+
|
|
24
|
+
Yields
|
|
25
|
+
-------
|
|
26
|
+
Transform
|
|
27
|
+
"""
|
|
28
|
+
this_seq = []
|
|
29
|
+
for t in seq.transforms:
|
|
30
|
+
if t.source_space is not None and t.target_space is not None:
|
|
31
|
+
yield t
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
this_seq.append(t)
|
|
35
|
+
if t.target_space is not None:
|
|
36
|
+
yield TransformSequence(this_seq)
|
|
37
|
+
this_seq = []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TransformGraph[ArrayT]:
|
|
41
|
+
"""Transform between any number of arbitrary spaces/ coordinate systems.
|
|
42
|
+
|
|
43
|
+
Finds the shortest path for transforming one space
|
|
44
|
+
into another, via some intermediate spaces.
|
|
45
|
+
|
|
46
|
+
Populate with `my_transform_graph.add_transforms(my_transforms)`.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.graph = nx.DiGraph()
|
|
51
|
+
self.ndim: set[int] | None = None
|
|
52
|
+
|
|
53
|
+
def add_transforms(self, transforms: Iterable[Transform[ArrayT]]) -> int:
|
|
54
|
+
"""
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
transforms : Iterable[Transform[ArrayT]]
|
|
58
|
+
Transforms which must have a source and target space defined.
|
|
59
|
+
TransformSequences are split out if their inner transforms'
|
|
60
|
+
spaces are defined.
|
|
61
|
+
|
|
62
|
+
Raises
|
|
63
|
+
------
|
|
64
|
+
ValueError
|
|
65
|
+
Undefined source and target spaces.
|
|
66
|
+
"""
|
|
67
|
+
# TODO: weighting of split-out sequences could be problematic
|
|
68
|
+
edges: dict[tuple[SpaceRef, SpaceRef], Transform[ArrayT]] = dict()
|
|
69
|
+
self.get_sequence.cache_clear()
|
|
70
|
+
|
|
71
|
+
ndim = self.ndim
|
|
72
|
+
|
|
73
|
+
for t in transforms:
|
|
74
|
+
ndim = dim_intersection(ndim, t.ndim)
|
|
75
|
+
if ndim is not None and len(ndim) == 0:
|
|
76
|
+
raise ValueError("This TransformGraph supports no dimensionality")
|
|
77
|
+
|
|
78
|
+
if isinstance(t, TransformSequence):
|
|
79
|
+
ts = list(split_sequence(t))
|
|
80
|
+
else:
|
|
81
|
+
ts = [t]
|
|
82
|
+
|
|
83
|
+
for t2 in ts:
|
|
84
|
+
if chain_or(t2.source_space, t2.target_space, default=None) is None:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"All transforms in a graph "
|
|
87
|
+
"need explicit source and target spaces"
|
|
88
|
+
)
|
|
89
|
+
edges[(t2.source_space, t2.target_space)] = t2
|
|
90
|
+
|
|
91
|
+
self.ndim = ndim
|
|
92
|
+
|
|
93
|
+
count = 0
|
|
94
|
+
|
|
95
|
+
for (src, tgt), t in edges.items():
|
|
96
|
+
self.graph.add_edge(src, tgt, transform=t)
|
|
97
|
+
count += 1
|
|
98
|
+
if (tgt, src) not in edges:
|
|
99
|
+
try:
|
|
100
|
+
self.graph.add_edge(tgt, src, transform=~t)
|
|
101
|
+
count += 1
|
|
102
|
+
except NotImplementedError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
return count
|
|
106
|
+
|
|
107
|
+
@lru_cache()
|
|
108
|
+
def get_sequence(
|
|
109
|
+
self,
|
|
110
|
+
source_space: SpaceRef,
|
|
111
|
+
target_space: SpaceRef,
|
|
112
|
+
simplify=False,
|
|
113
|
+
drop_inverse=False,
|
|
114
|
+
) -> TransformSequence[ArrayT]:
|
|
115
|
+
"""Get the shortest TransformSequence for transforming between two spaces.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
source_space : SpaceRef
|
|
120
|
+
target_space : SpaceRef
|
|
121
|
+
simplify : bool
|
|
122
|
+
Whether to simplify the transform sequence; see `TransformSequence.simplify`.
|
|
123
|
+
drop_inverse : bool
|
|
124
|
+
If `simplify==True`, whether to drop explicit inverses.
|
|
125
|
+
See `TransformSequence.simplify` for details.
|
|
126
|
+
Ignored if `simplify==False`.
|
|
127
|
+
|
|
128
|
+
Returns
|
|
129
|
+
-------
|
|
130
|
+
TransformSequence[ArrayT]
|
|
131
|
+
"""
|
|
132
|
+
path = nx.shortest_path(self.graph, source_space, target_space)
|
|
133
|
+
if len(path) <= 1:
|
|
134
|
+
transforms = []
|
|
135
|
+
else:
|
|
136
|
+
transforms = [
|
|
137
|
+
self.graph.edges[src, tgt]["transform"] for src, tgt in window(path, 2)
|
|
138
|
+
]
|
|
139
|
+
seq = TransformSequence(
|
|
140
|
+
transforms,
|
|
141
|
+
spaces=(source_space, target_space),
|
|
142
|
+
)
|
|
143
|
+
if simplify:
|
|
144
|
+
seq = seq.simplify(drop_inverse=drop_inverse)
|
|
145
|
+
return seq
|
|
146
|
+
|
|
147
|
+
def transform(
|
|
148
|
+
self, source_space: SpaceRef, target_space: SpaceRef, coords: ArrayT
|
|
149
|
+
) -> ArrayT:
|
|
150
|
+
"""Transform coordinates from one space to another,
|
|
151
|
+
possibly via intermediates.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
source_space : SpaceRef
|
|
156
|
+
target_space : SpaceRef
|
|
157
|
+
coords : ArrayT
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
ArrayT
|
|
162
|
+
"""
|
|
163
|
+
t = self.get_sequence(source_space, target_space)
|
|
164
|
+
return t.apply(coords)
|
|
165
|
+
|
|
166
|
+
def __iter__(self) -> Iterator[Transform[ArrayT]]:
|
|
167
|
+
"""Iterate through the transforms present in the graph.
|
|
168
|
+
|
|
169
|
+
Includes inferred reverse transforms.
|
|
170
|
+
|
|
171
|
+
N.B. the `__iter__` method of some popular graph libraries like networkx iterate through nodes,
|
|
172
|
+
where this effectively iterates through edges.
|
|
173
|
+
|
|
174
|
+
Yields
|
|
175
|
+
-------
|
|
176
|
+
Transform[ArrayT]
|
|
177
|
+
|
|
178
|
+
Examples
|
|
179
|
+
--------
|
|
180
|
+
Create a new transform graph using another
|
|
181
|
+
|
|
182
|
+
>>> new_tgraph = TransformGraph([extra_transform, *old_tgraph])
|
|
183
|
+
|
|
184
|
+
"""
|
|
185
|
+
for _, _, t in self.graph.edges.data("transform"):
|
|
186
|
+
yield t
|
|
187
|
+
|
|
188
|
+
def to_device(self, xp, device=None) -> TransformGraph[ArrayT]:
|
|
189
|
+
result: TransformGraph[ArrayT] = TransformGraph()
|
|
190
|
+
for src, tgt, t in self.graph.edges.data("transform"):
|
|
191
|
+
result.graph.add_edge(src, tgt, transform=t.to_device(xp, device))
|
|
192
|
+
return result
|
transformnd/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Implementations of some common transforms."""
|
|
2
|
+
|
|
3
|
+
from .affine import Affine
|
|
4
|
+
from .reflection import Reflect
|
|
5
|
+
from .simple import Identity, Scale, Translate
|
|
6
|
+
from .map_axis import MapAxis
|
|
7
|
+
from .bijection import Bijection
|
|
8
|
+
from .by_dimension import ByDimension
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Affine",
|
|
12
|
+
"Identity",
|
|
13
|
+
"Reflect",
|
|
14
|
+
"Scale",
|
|
15
|
+
"Translate",
|
|
16
|
+
"MapAxis",
|
|
17
|
+
"Bijection",
|
|
18
|
+
"ByDimension",
|
|
19
|
+
]
|