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/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,10 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class Extents[ArrayT](ABC):
5
+ """Base class for determining whether coordinates are "inside" a space."""
6
+
7
+ ndim: set[int] | None = None
8
+
9
+ @abstractmethod
10
+ def contains(self, coords: ArrayT) -> ArrayT: ...
@@ -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
+ ]