transformnd 0.4.2__tar.gz → 0.6.0__tar.gz

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.
Files changed (31) hide show
  1. {transformnd-0.4.2 → transformnd-0.6.0}/PKG-INFO +6 -7
  2. {transformnd-0.4.2 → transformnd-0.6.0}/README.md +5 -6
  3. {transformnd-0.4.2 → transformnd-0.6.0}/pyproject.toml +1 -1
  4. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/__init__.py +6 -1
  5. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/pandas.py +7 -4
  6. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/polars.py +7 -4
  7. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/shapely.py +10 -6
  8. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/base.py +32 -4
  9. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/graph.py +69 -148
  10. transformnd-0.6.0/src/transformnd/transforms/__init__.py +31 -0
  11. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/affine.py +2 -2
  12. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/by_dimension.py +22 -1
  13. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/moving_least_squares.py +5 -2
  14. transformnd-0.6.0/src/transformnd/transforms/project_axis.py +173 -0
  15. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/thinplate.py +9 -4
  16. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/vector_field.py +8 -2
  17. transformnd-0.4.2/src/transformnd/transforms/__init__.py +0 -19
  18. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/__init__.py +0 -0
  19. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/base.py +0 -0
  20. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/adapters/bounding_box.py +0 -0
  21. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/constants.py +0 -0
  22. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/extents/__init__.py +0 -0
  23. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/extents/base.py +0 -0
  24. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/extents/bounding_box.py +0 -0
  25. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/py.typed +0 -0
  26. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/bijection.py +0 -0
  27. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/map_axis.py +0 -0
  28. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/reflection.py +0 -0
  29. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/transforms/simple.py +0 -0
  30. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/types.py +0 -0
  31. {transformnd-0.4.2 → transformnd-0.6.0}/src/transformnd/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transformnd
3
- Version: 0.4.2
3
+ Version: 0.6.0
4
4
  Summary: ND coordinate transformations
5
5
  Author: Chris Barnes
6
6
  Author-email: Chris Barnes <chris.barnes@gerbi-gmb.de>
@@ -99,10 +99,10 @@ All transforms are accessed under the `transformnd.transforms` subpackage.
99
99
  | `MapAxis` | | Rearrange axes of the input coordinates |
100
100
  | `Affine` | | Multiply augmented coordinates by an affine transformation matrix. Can represent all of the above transformations. Can be composed with matrix multiplication `aff2 @ aff1`. |
101
101
  | `ByDimension` | | Apply different transformations to subsets of the input coordinates' dimensions |
102
- | `moving_least_squares.MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
103
- | `thin_plate_splines.ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
104
- | `vector_field.Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
105
- | `vector_field.Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |
102
+ | `MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
103
+ | `ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
104
+ | `Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
105
+ | `Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |
106
106
 
107
107
  Arbitrary transforms can be composed into a `TransformSequence` with `transform1 | transform2`.
108
108
  A graph of transforms between defined spaces can be traversed using the `TransformGraph`.
@@ -110,13 +110,12 @@ A graph of transforms between defined spaces can be traversed using the `Transfo
110
110
  ## Implemented adapters
111
111
 
112
112
  - Numpy arrays of shape `(..., D, ...)` (`transformnd.adapters.ReshapeAdapter`)
113
- - `meshio.Mesh` (`transformnd.adapters.meshio.MeshAdapter`)
114
113
  - `pandas.DataFrame` (`transformnd.adapters.pandas.PandasAdapter`)
115
114
  - Takes a subset of columns as a coordinate array
116
115
  - `polars.DataFrame` (`transformnd.adapters.polars.PolarsAdapter`)
117
116
  - Similar to the pandas adapter
118
117
  - Currently, only scalar columns are supported (e.g. not a single struct column with fields `x`, `y`, `z`)
119
- - Geometries from `shapely` (`transformnd.adapters.shapely.GeometryAdapter`)
118
+ - Geometries from `shapely` (`transformnd.adapters.shapely.ShapelyAdapter`)
120
119
  - Objects composed of transformable attributes (`transformnd.adapters.AttrAdapter`).
121
120
 
122
121
  ## Additional transforms and adapters
@@ -44,10 +44,10 @@ All transforms are accessed under the `transformnd.transforms` subpackage.
44
44
  | `MapAxis` | | Rearrange axes of the input coordinates |
45
45
  | `Affine` | | Multiply augmented coordinates by an affine transformation matrix. Can represent all of the above transformations. Can be composed with matrix multiplication `aff2 @ aff1`. |
46
46
  | `ByDimension` | | Apply different transformations to subsets of the input coordinates' dimensions |
47
- | `moving_least_squares.MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
48
- | `thin_plate_splines.ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
49
- | `vector_field.Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
50
- | `vector_field.Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |
47
+ | `MovingLeastSquares` | `movingleastsquares` | Landmark-based transformation. |
48
+ | `ThinPlateSplines` | `thinplatesplines` | Landmark-based transformation. |
49
+ | `Coordinates` | `vectorfield` for in-memory, `vectorfield-dask` for chunked | Look up output coordinates in a vector field indexed by the input coordinates |
50
+ | `Displacements` | `vectorfield`, `vectorfield-dask` for chunked | Look up translations in a vector field indexed by the input coordinates, and add them to input coordinates |
51
51
 
52
52
  Arbitrary transforms can be composed into a `TransformSequence` with `transform1 | transform2`.
53
53
  A graph of transforms between defined spaces can be traversed using the `TransformGraph`.
@@ -55,13 +55,12 @@ A graph of transforms between defined spaces can be traversed using the `Transfo
55
55
  ## Implemented adapters
56
56
 
57
57
  - Numpy arrays of shape `(..., D, ...)` (`transformnd.adapters.ReshapeAdapter`)
58
- - `meshio.Mesh` (`transformnd.adapters.meshio.MeshAdapter`)
59
58
  - `pandas.DataFrame` (`transformnd.adapters.pandas.PandasAdapter`)
60
59
  - Takes a subset of columns as a coordinate array
61
60
  - `polars.DataFrame` (`transformnd.adapters.polars.PolarsAdapter`)
62
61
  - Similar to the pandas adapter
63
62
  - Currently, only scalar columns are supported (e.g. not a single struct column with fields `x`, `y`, `z`)
64
- - Geometries from `shapely` (`transformnd.adapters.shapely.GeometryAdapter`)
63
+ - Geometries from `shapely` (`transformnd.adapters.shapely.ShapelyAdapter`)
65
64
  - Objects composed of transformable attributes (`transformnd.adapters.AttrAdapter`).
66
65
 
67
66
  ## Additional transforms and adapters
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transformnd"
3
- version = "0.4.2"
3
+ version = "0.6.0"
4
4
  description = "ND coordinate transformations"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Chris Barnes", email = "chris.barnes@gerbi-gmb.de" }]
@@ -12,7 +12,6 @@ Implement your own adapter by inheriting from `BaseAdapter`.
12
12
 
13
13
  See `.pandas.DataFrameAdapter` for an example of creating an adapter
14
14
  for an external type.
15
-
16
15
  """
17
16
 
18
17
  from .base import (
@@ -23,6 +22,9 @@ from .base import (
23
22
  ReshapeAdapter,
24
23
  SimpleAdapter,
25
24
  )
25
+ from .pandas import PandasAdapter
26
+ from .polars import PolarsAdapter
27
+ from .shapely import ShapelyAdapter
26
28
 
27
29
  __all__ = [
28
30
  "BaseAdapter",
@@ -31,4 +33,7 @@ __all__ = [
31
33
  "FnAdapter",
32
34
  "AttrAdapter",
33
35
  "ReshapeAdapter",
36
+ "PandasAdapter",
37
+ "PolarsAdapter",
38
+ "ShapelyAdapter",
34
39
  ]
@@ -1,15 +1,18 @@
1
1
  """Adapt pandas DataFrames for transformation."""
2
2
 
3
3
  from collections.abc import Hashable
4
+ from typing import TYPE_CHECKING
4
5
 
5
- import pandas as pd
6
6
  import numpy as np
7
7
 
8
8
  from ..base import Transform
9
9
  from .base import BaseAdapter
10
10
 
11
+ if TYPE_CHECKING:
12
+ import pandas as pd
11
13
 
12
- class PandasAdapter(BaseAdapter[pd.DataFrame, np.ndarray]):
14
+
15
+ class PandasAdapter(BaseAdapter["pd.DataFrame", np.ndarray]):
13
16
  def __init__(self, columns: list[Hashable]):
14
17
  """Adapt transformation for coordinates stored in a pandas DataFrame.
15
18
 
@@ -21,8 +24,8 @@ class PandasAdapter(BaseAdapter[pd.DataFrame, np.ndarray]):
21
24
  self.columns = columns
22
25
 
23
26
  def apply(
24
- self, transform: Transform, df: pd.DataFrame, in_place: bool = False
25
- ) -> pd.DataFrame:
27
+ self, transform: Transform, df: "pd.DataFrame", in_place: bool = False
28
+ ) -> "pd.DataFrame":
26
29
  """Transform the dataframe, optionally in-place.
27
30
 
28
31
  Parameters
@@ -1,13 +1,16 @@
1
1
  """Adapt polars DataFrames for transformation."""
2
2
 
3
- import polars as pl
3
+ from typing import TYPE_CHECKING
4
4
  import numpy as np
5
5
 
6
6
  from ..base import Transform
7
7
  from .base import BaseAdapter
8
8
 
9
+ if TYPE_CHECKING:
10
+ import polars as pl
9
11
 
10
- class PolarsAdapter(BaseAdapter[pl.DataFrame, np.ndarray]):
12
+
13
+ class PolarsAdapter(BaseAdapter["pl.DataFrame", np.ndarray]):
11
14
  def __init__(self, columns: list[str]):
12
15
  """Adapt transformation for coordinates stored in a polars DataFrame.
13
16
 
@@ -19,8 +22,8 @@ class PolarsAdapter(BaseAdapter[pl.DataFrame, np.ndarray]):
19
22
  self.columns = columns
20
23
 
21
24
  def apply(
22
- self, transform: Transform, df: pl.DataFrame, in_place: bool = False
23
- ) -> pl.DataFrame:
25
+ self, transform: Transform, df: "pl.DataFrame", in_place: bool = False
26
+ ) -> "pl.DataFrame":
24
27
  """Transform the dataframe, optionally in-place.
25
28
 
26
29
  Parameters
@@ -1,21 +1,24 @@
1
1
  import logging
2
+ from typing import TYPE_CHECKING
2
3
 
3
4
  import numpy as np
4
- import shapely
5
- from shapely.geometry.base import BaseGeometry
6
- from shapely.coords import CoordinateSequence
7
5
 
8
6
  from ..base import Transform, ArrayT
9
7
  from .base import BaseAdapter
10
8
 
9
+ if TYPE_CHECKING:
10
+ from shapely.geometry.base import BaseGeometry
11
+ from shapely.coords import CoordinateSequence
12
+
13
+
11
14
  logger = logging.getLogger(__name__)
12
15
 
13
16
 
14
- def as_numpy(coords: CoordinateSequence) -> np.ndarray:
17
+ def as_numpy(coords: "CoordinateSequence") -> np.ndarray:
15
18
  return np.asarray(coords)
16
19
 
17
20
 
18
- class GeometryAdapter(BaseAdapter[BaseGeometry, ArrayT]):
21
+ class ShapelyAdapter(BaseAdapter["BaseGeometry", ArrayT]):
19
22
  """Transform shapely geometries.
20
23
 
21
24
  As well as the generic `apply()`,
@@ -27,7 +30,7 @@ class GeometryAdapter(BaseAdapter[BaseGeometry, ArrayT]):
27
30
  N.B. shapely geometries' coordinates are in `XY(Z)` order
28
31
  """
29
32
 
30
- def apply[T: BaseGeometry](
33
+ def apply[T: "BaseGeometry"](
31
34
  self,
32
35
  transform: Transform,
33
36
  obj: T,
@@ -51,6 +54,7 @@ class GeometryAdapter(BaseAdapter[BaseGeometry, ArrayT]):
51
54
  T
52
55
  An object of the same type as the input.
53
56
  """
57
+ import shapely
54
58
 
55
59
  def fn(coords: np.ndarray) -> np.ndarray:
56
60
  c = coords.copy()
@@ -370,6 +370,20 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
370
370
  spaces = [s for s in spaces if s is not None]
371
371
  return spaces
372
372
 
373
+ def split(self) -> Iterator[Transform[ArrayT]]:
374
+ """Split the sequence where an intermediate space is known."""
375
+ this_seq = []
376
+
377
+ for t in self.transforms:
378
+ if t.spaces.source is not None and t.spaces.target is not None:
379
+ yield t
380
+ continue
381
+
382
+ this_seq.append(t)
383
+ if t.spaces.target is not None:
384
+ yield type(self)(this_seq)
385
+ this_seq = []
386
+
373
387
  def __str__(self) -> str:
374
388
  cls_name = type(self).__name__
375
389
  spaces_str = "->".join(space_str(s) for s in self.list_spaces())
@@ -383,6 +397,21 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
383
397
  def is_identity(self) -> bool:
384
398
  return all(t.is_identity() for t in self)
385
399
 
400
+ def flatten(self, drop_inverse: bool = True) -> Self:
401
+ """Flatten nested sequences."""
402
+ from .transforms.bijection import Bijection
403
+
404
+ out: list[Transform[ArrayT]] = []
405
+
406
+ for t in self.transforms:
407
+ if drop_inverse and isinstance(t, Bijection):
408
+ t = t.forward
409
+ if isinstance(t, TransformSequence):
410
+ out.extend(t.flatten())
411
+ else:
412
+ out.append(t)
413
+ return TransformSequence(out, spaces=self.spaces) # type:ignore
414
+
386
415
  def simplify(self, drop_inverse: bool = True):
387
416
  """Reduce the number of transformations in this sequence if possible.
388
417
 
@@ -396,14 +425,13 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
396
425
  Does not check whether transforms invert each other,
397
426
  e.g. `Translation(1) | Translation(-1)`.
398
427
  """
399
- from .transforms.bijection import Bijection
400
428
  from .transforms import Identity
401
429
 
402
430
  out: list[Transform[ArrayT]] = []
403
431
  affine = None
404
- for t in self.transforms:
405
- if drop_inverse and isinstance(t, Bijection):
406
- t = t.forward
432
+ for t in self.flatten(drop_inverse):
433
+ if t.is_identity():
434
+ continue
407
435
 
408
436
  new_affine = t.to_affine()
409
437
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  from functools import lru_cache
5
- from collections.abc import Iterable, Iterator
5
+ from collections.abc import Callable, Iterator
6
6
  import logging
7
- from itertools import chain, pairwise
7
+ from itertools import pairwise
8
8
  from types import ModuleType
9
+ from typing import Any
9
10
 
10
11
  import networkx as nx
11
12
 
@@ -16,6 +17,9 @@ from .types import Spaces
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
20
+ TRANSFORM_KEY = "_transform"
21
+ WeightFn = Callable[[SpaceRef, SpaceRef, dict[str, Any]], int]
22
+
19
23
 
20
24
  def split_sequence(seq: TransformSequence[ArrayT]) -> Iterator[Transform[ArrayT]]:
21
25
  """Split a TransformSequence into Transforms with spaces defined.
@@ -46,6 +50,15 @@ def split_sequence(seq: TransformSequence[ArrayT]) -> Iterator[Transform[ArrayT]
46
50
  this_seq = []
47
51
 
48
52
 
53
+ def normalise_edge_weight_fn(w: str | WeightFn | None) -> WeightFn:
54
+ if w is None:
55
+ return lambda _s, _t, _d: 1
56
+ elif isinstance(w, str):
57
+ return lambda _s, _t, d: d.get(w, 1)
58
+ else:
59
+ return w
60
+
61
+
49
62
  class TransformGraph[ArrayT]:
50
63
  """Transform between any number of arbitrary spaces/ coordinate systems.
51
64
 
@@ -57,18 +70,14 @@ class TransformGraph[ArrayT]:
57
70
 
58
71
  def __init__(
59
72
  self,
60
- transforms: Iterable[Transform[ArrayT]] | None = None,
61
- and_inverse: bool = True,
62
73
  ):
63
74
  """Create an transform graph, optionally with some starting transforms.
64
75
 
65
76
  See the `TransformGraph.add_transforms` documentation for restrictions on the
66
77
  given transforms.
67
78
  """
68
- self.graph = nx.DiGraph()
79
+ self.graph = nx.MultiDiGraph()
69
80
  self.space_ndims: dict[SpaceRef, int] = dict()
70
- if transforms is not None:
71
- self.add_transforms(transforms, and_inverse)
72
81
 
73
82
  def _update_spaces(
74
83
  self,
@@ -99,48 +108,22 @@ class TransformGraph[ArrayT]:
99
108
  transform: Transform[ArrayT],
100
109
  source: SpaceRef | None,
101
110
  target: SpaceRef | None,
102
- and_inverse: bool,
111
+ edge_data: dict[str, Any] | None,
103
112
  ) -> list[tuple[SpaceRef, SpaceRef]]:
104
113
  """Clearing the get_sequence cache and splitting sequences and bijections should be handled outside this method."""
105
114
  out = []
106
115
 
107
116
  src, tgt = self._update_spaces(transform, source, target)
108
117
 
109
- if self.graph.has_edge(src, tgt):
110
- logger.warning(f"Replacing existing edge between {src} and {tgt}")
118
+ if edge_data is None:
119
+ edge_data = dict()
111
120
 
112
- self.graph.add_edge(src, tgt, transform=transform)
113
- out.append((src, tgt))
114
- if and_inverse:
115
- out.extend(self._add_inverse(transform, src, tgt))
116
- return out
117
-
118
- def _add_inverse(
119
- self,
120
- transform: Transform[ArrayT],
121
- source: SpaceRef | None,
122
- target: SpaceRef | None,
123
- ) -> list[tuple[SpaceRef, SpaceRef]]:
124
- src, tgt = self._update_spaces(transform, source, target)
125
- out = []
121
+ if TRANSFORM_KEY in edge_data:
122
+ raise ValueError(f"Must not use the key '{TRANSFORM_KEY}' in edge_data")
126
123
 
127
- if self.graph.has_edge(tgt, src):
128
- logger.debug(
129
- "Implicit reverse edge not added to graph as explicit edge already exists for %s->%s",
130
- tgt,
131
- src,
132
- )
133
- elif t := transform.invert():
134
- if isinstance(t, Bijection):
135
- t = t.forward
136
- self.graph.add_edge(tgt, src, transform=t)
137
- out.append((tgt, src))
138
- else:
139
- logger.debug(
140
- "Reverse edge not added to graph for non-invertible %s->%s transform",
141
- src,
142
- tgt,
143
- )
124
+ d = {TRANSFORM_KEY: transform, **edge_data}
125
+ self.graph.add_edge(src, tgt, **d)
126
+ out.append((src, tgt))
144
127
  return out
145
128
 
146
129
  def add_transform(
@@ -148,20 +131,21 @@ class TransformGraph[ArrayT]:
148
131
  transform: Transform[ArrayT],
149
132
  source: SpaceRef | None = None,
150
133
  target: SpaceRef | None = None,
151
- and_inverse: bool = True,
134
+ *,
135
+ edge_data: dict[str, Any] | None = None,
152
136
  ) -> list[tuple[SpaceRef, SpaceRef]]:
153
- """Add a transform to the graph, optionally with its inverse.
154
-
155
- If the given transform is a `TransformSequence`,
156
- it will be split down into subsequences where intermediate spaces are known.
137
+ """Add a transform to the graph.
157
138
 
158
139
  If the given transform is a `Bijection`,
159
- its forward component will be added as an independent edges;
160
- if `and_inverse=True`, the same will be done with the inverse component.
140
+ only the forward component will be added as an independent edges.
141
+
142
+ This method will NOT overwrite intermediate edges.
161
143
 
162
- This method will overwrite existing edges.
163
- Implicit inverses calculated from the given transform will not overwrite existing explicit edges,
164
- except in the case of the `Bijection`.
144
+ N.B. Previously this method implicitly added inverse edges where possible.
145
+ Now these edges must be added explicitly by calling `add_transform(~transform)`.
146
+ Additionally, previously `TransformSequence`s would be split out into multiple edges
147
+ if any intermediate spaces were defined;
148
+ now these edges must be added explicitly with the `TransformSequence.split` method.
165
149
 
166
150
  Parameters
167
151
  ----------
@@ -171,8 +155,10 @@ class TransformGraph[ArrayT]:
171
155
  May be omitted if `transform` has its source space defined.
172
156
  target
173
157
  May be omitted if `transform` has its target space defined.
174
- and_inverse
175
- Try to add the reverse edge by inverting the transform if possible; default True
158
+ edge_data
159
+ Dict of string keys to arbitrary values to associate with an edge.
160
+ Used during path-finding.
161
+ Must not have the `"_transform"` key.
176
162
 
177
163
  Returns
178
164
  -------
@@ -180,111 +166,32 @@ class TransformGraph[ArrayT]:
180
166
  List of `(src, tgt)` edges added to the graph.
181
167
  """
182
168
  out: list[tuple[SpaceRef, SpaceRef]] = []
183
- if isinstance(transform, TransformSequence):
184
- # TODO: weighting of split-out sequences could be problematic
185
- ts = split_sequence(transform)
186
- out.extend(
187
- chain.from_iterable(
188
- self.add_transform(t, None, None, and_inverse) for t in ts
189
- )
190
- )
191
-
192
- elif isinstance(transform, Bijection):
169
+ if isinstance(transform, Bijection):
193
170
  out.extend(
194
171
  self.add_transform(
195
172
  transform.forward,
196
173
  source,
197
174
  target,
198
- False,
175
+ edge_data=edge_data,
199
176
  )
200
177
  )
201
- if and_inverse:
202
- out.extend(
203
- self.add_transform(
204
- transform.inverse,
205
- target,
206
- source,
207
- False,
208
- )
209
- )
210
178
 
211
179
  else:
212
- out.extend(self._add_transform(transform, source, target, and_inverse))
180
+ out.extend(self._add_transform(transform, source, target, edge_data))
213
181
 
214
182
  if out:
215
183
  self.get_sequence.cache_clear()
216
184
 
217
185
  return out
218
186
 
219
- def add_transforms(
220
- self,
221
- transforms: Iterable[Transform[ArrayT]],
222
- and_inverse: bool = True,
223
- ) -> list[tuple[SpaceRef, SpaceRef]]:
224
- """Bulk-add transformations to the graph.
225
-
226
- Every given transform must have a source and target space defined;
227
- these spaces are the nodes of the graph.
228
-
229
- This method is preferred over `TransformGraph.add_transform`
230
- when some reverse edges are explicitly defined
231
- and you don't want them to be overridden by implicit reverse edges
232
- when `and_inverse=True`.
233
-
234
- `Bijection` s and `TransformSequence` s will be split out as documented in
235
- `TransformGraph.add_transform`.
236
-
237
- Note that a single `TransformSequence` is itself an `Iterable[Transform]`
238
- and so could be used as the `transforms` argument.
239
- However, a `TransformSequence` does not require that all of its members
240
- have explicit source and target spaces,
241
- where the `transforms` argument here does,
242
- so not all `TransformSequence` s can be used directly as the argument
243
- (wrap them in a list instead or use `TransformGraph.add_transform`).
244
-
245
- Parameters
246
- ----------
247
- transforms
248
- Transforms which must have a source and target space defined.
249
- and_inverse
250
- Invert the transformations and add them too.
251
-
252
- Returns
253
- -------
254
- list[tuple[SpaceRef, SpaceRef]]
255
- List of `(src, tgt)` edges added to the graph.
256
- """
257
- if isinstance(transforms, TransformSequence):
258
- logger.warning(
259
- "add_transforms() argument is a TransformSequence, "
260
- "which allows undefined intermediate spaces, "
261
- "in which case this method will fail. "
262
- "Prefer the add_transform() argument for single logical transforms, "
263
- "or wrap the given argument in a collection (e.g. a list)."
264
- )
265
-
266
- forwards = []
267
- for t in transforms:
268
- forwards.extend(self.add_transform(t, and_inverse=False))
269
-
270
- if not and_inverse:
271
- return forwards
272
-
273
- out = list(forwards)
274
-
275
- # add inverses in second stage to prevent implicit reverse transforms blocking explicit
276
- for src, tgt in forwards:
277
- t = self.graph.edges[src, tgt]["transform"]
278
- out.extend(self._add_inverse(t, src, tgt))
279
-
280
- return out
281
-
282
187
  @lru_cache()
283
188
  def get_sequence(
284
189
  self,
285
190
  source_space: SpaceRef,
286
191
  target_space: SpaceRef,
287
192
  full: bool = False,
193
+ *,
194
+ weight: None | str | WeightFn = None,
288
195
  ) -> TransformSequence[ArrayT]:
289
196
  """Get the shortest TransformSequence for transforming between two spaces.
290
197
 
@@ -297,25 +204,32 @@ class TransformGraph[ArrayT]:
297
204
  full
298
205
  By default, simplifies consecutive affines and drops bijections' inverse form.
299
206
  If `full` is True, keeps each transformation as-is.
207
+ weight
208
+ str key in the `edge_data` dict given when an edge was added,
209
+ or a function to determine a weight from the args `src_space, tgt_space, edge_data`,
210
+ or None (all weights are 1).
300
211
 
301
212
  Returns
302
213
  -------
303
214
  TransformSequence[ArrayT]
304
215
  The shortest transform sequence between the spaces.
305
216
  """
306
- path = nx.shortest_path(self.graph, source_space, target_space)
307
- if len(path) <= 1:
308
- transforms = []
309
- else:
310
- transforms = [
311
- self.graph.edges[src, tgt]["transform"] for src, tgt in pairwise(path)
312
- ]
217
+ path = nx.shortest_path(self.graph, source_space, target_space, weight) # type:ignore
218
+ transforms = []
219
+ wfn = normalise_edge_weight_fn(weight)
220
+
221
+ for src, tgt in pairwise(path):
222
+ edges = self.graph[src][tgt]
223
+ transforms.append(
224
+ min(edges.values(), key=lambda d: wfn(src, tgt, d))[TRANSFORM_KEY]
225
+ )
226
+
313
227
  seq = TransformSequence(
314
228
  transforms,
315
229
  spaces=Spaces(source_space, target_space),
316
230
  )
317
231
  if not full:
318
- seq = seq.simplify(drop_inverse=False)
232
+ seq = seq.simplify(drop_inverse=True)
319
233
  return seq
320
234
 
321
235
  def transform(
@@ -323,6 +237,8 @@ class TransformGraph[ArrayT]:
323
237
  source_space: SpaceRef,
324
238
  target_space: SpaceRef,
325
239
  coords: ArrayT,
240
+ *,
241
+ weight: None | str | WeightFn = None,
326
242
  ) -> ArrayT:
327
243
  """Transform coordinates from one space to another,
328
244
  possibly via intermediates.
@@ -335,13 +251,18 @@ class TransformGraph[ArrayT]:
335
251
  The target coordinate space.
336
252
  coords
337
253
  The coordinates to transform.
254
+ weight
255
+ str key in the `edge_data` dict given when an edge was added,
256
+ or a function to determine a weight from the args `src_space, tgt_space, edge_data`,
257
+ or None (all weights are 1).
258
+
338
259
 
339
260
  Returns
340
261
  -------
341
262
  ArrayT
342
263
  The transformed coordinates.
343
264
  """
344
- t = self.get_sequence(source_space, target_space)
265
+ t = self.get_sequence(source_space, target_space, weight=weight)
345
266
  return t.apply(coords)
346
267
 
347
268
  def __iter__(self) -> Iterator[Transform[ArrayT]]:
@@ -364,13 +285,13 @@ class TransformGraph[ArrayT]:
364
285
  >>> new_tgraph = TransformGraph([extra_transform, *old_tgraph])
365
286
 
366
287
  """
367
- for _, _, t in self.graph.edges.data("transform"):
288
+ for _, _, t in self.graph.edges.data(TRANSFORM_KEY):
368
289
  yield t
369
290
 
370
291
  def to_device(
371
292
  self, xp: ModuleType, device: str | None = None
372
293
  ) -> TransformGraph[ArrayT]:
373
294
  result: TransformGraph[ArrayT] = TransformGraph()
374
- for src, tgt, t in self.graph.edges.data("transform"):
295
+ for src, tgt, t in self.graph.edges.data(TRANSFORM_KEY):
375
296
  result.graph.add_edge(src, tgt, transform=t.to_device(xp, device))
376
297
  return result
@@ -0,0 +1,31 @@
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 .project_axis import ProjectAxis, Insert, Remove
9
+ from .by_dimension import ByDimension, SubTransform
10
+ from .vector_field import Coordinates, Displacements
11
+ from .moving_least_squares import MovingLeastSquares
12
+ from .thinplate import ThinPlateSplines
13
+
14
+ __all__ = [
15
+ "Affine",
16
+ "Identity",
17
+ "ProjectAxis",
18
+ "Insert",
19
+ "Remove",
20
+ "Reflect",
21
+ "Scale",
22
+ "Translate",
23
+ "MapAxis",
24
+ "Bijection",
25
+ "ByDimension",
26
+ "SubTransform",
27
+ "Coordinates",
28
+ "Displacements",
29
+ "MovingLeastSquares",
30
+ "ThinPlateSplines",
31
+ ]
@@ -42,7 +42,7 @@ class Affine(Transform[ArrayT]):
42
42
  ----------
43
43
  matrix
44
44
  Affine transformation matrix,
45
- i.e. a 2D array-like with shape `(Di + 1, Do + 1)`,
45
+ i.e. a 2D array-like with shape `(Do + 1, Di + 1)`,
46
46
  where the bottom row is all 0s except in the rightmost column, which is 1.
47
47
  spaces
48
48
  Optional source and target spaces
@@ -64,7 +64,7 @@ class Affine(Transform[ArrayT]):
64
64
  f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})."
65
65
  )
66
66
 
67
- super().__init__(NDims(m.shape[0] - 1, m.shape[1] - 1), spaces=spaces)
67
+ super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces)
68
68
 
69
69
  self.matrix: np.ndarray = m
70
70
 
@@ -8,7 +8,10 @@ from ..types import NDims, Spaces
8
8
 
9
9
 
10
10
  class SubTransform[ArrayT]:
11
- """Transformation to apply to subsets of the input dimensions and which output dimensions they calculate."""
11
+ """Component of the `ByDimension` transformation.
12
+
13
+ Transformation to apply to subsets of the input dimensions and which output dimensions they calculate.
14
+ """
12
15
 
13
16
  def __init__(
14
17
  self,
@@ -16,6 +19,24 @@ class SubTransform[ArrayT]:
16
19
  input_axes: list[int],
17
20
  output_axes: list[int] | None = None,
18
21
  ):
22
+ """
23
+ Parameters
24
+ ----------
25
+ transform
26
+ Transformation to apply to the subset of axes.
27
+ input_axes
28
+ Which axes to apply the transformation to, in order.
29
+ The length must match the input dimensionality of `transform`.
30
+ output_axes
31
+ Which axes to apply the transformation to, in order.
32
+ The length must match the input dimensionality of `transform`.
33
+ If None, re-use the input axes.
34
+
35
+ Raises
36
+ ------
37
+ ValueError
38
+ `transform`'s dimensionality does not match the input/output axes.
39
+ """
19
40
 
20
41
  self.input_axes = input_axes
21
42
  if output_axes is None:
@@ -7,7 +7,6 @@ Requires the `movingleastsquares` extra.
7
7
  from array_api_compat import array_namespace
8
8
  import numpy as np
9
9
  from typing import Self
10
- from molesq.transform import Transformer as _Transformer
11
10
 
12
11
  from ..base import Transform
13
12
  from ..types import NDims, Spaces
@@ -18,6 +17,8 @@ class MovingLeastSquares(Transform[np.ndarray]):
18
17
  """Moving least squares transformation.
19
18
 
20
19
  Deform based on a matched pairs of source and target control points; see <https://dl.acm.org/doi/10.1145/1141911.1141920>
20
+
21
+ REQUIRES: `movingleastsquares` extra.
21
22
  """
22
23
 
23
24
  def __init__(
@@ -39,9 +40,11 @@ class MovingLeastSquares(Transform[np.ndarray]):
39
40
  spaces
40
41
  Optional source and target spaces
41
42
  """
43
+ from molesq.transform import Transformer
44
+
42
45
  s = as_floats(source_control_points)
43
46
  t = as_floats(target_control_points)
44
- self._transformer = _Transformer(s, t)
47
+ self._transformer = Transformer(s, t)
45
48
  super().__init__(
46
49
  NDims(
47
50
  s.shape[1],
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+ from abc import ABC, abstractmethod
3
+ from copy import copy
4
+ from typing import Self, Sequence
5
+
6
+ import numpy as np
7
+ from array_api_compat import array_namespace
8
+ from transformnd.transforms import Affine
9
+ from transformnd.types import NDims, Spaces
10
+ from dataclasses import dataclass
11
+ from ..base import Transform
12
+ from ..types import ArrayT
13
+
14
+
15
+ @dataclass(frozen=True, eq=True)
16
+ class BaseOperation(ABC):
17
+ idx: int
18
+ """Which axis to apply the operation to."""
19
+
20
+ def __post_init__(self):
21
+ if self.idx < 0:
22
+ raise ValueError("insert/remove idx must be positive")
23
+
24
+ @abstractmethod
25
+ def check(self, ndim: int) -> int: ...
26
+
27
+ @abstractmethod
28
+ def invert(self) -> BaseOperation: ...
29
+
30
+
31
+ @dataclass(frozen=True, eq=True)
32
+ class Insert(BaseOperation):
33
+ """Component of the `ProjectAxis` transform which inserts a new axis."""
34
+
35
+ def check(self, ndim: int) -> int:
36
+ if self.idx > ndim or self.idx <= -ndim:
37
+ raise ValueError(
38
+ f"Index {self.idx} is out of range for dimensionality {ndim}"
39
+ )
40
+ return ndim + 1
41
+
42
+ def invert(self) -> Remove:
43
+ return Remove(self.idx)
44
+
45
+
46
+ @dataclass(frozen=True, eq=True)
47
+ class Remove(BaseOperation):
48
+ """Component of the `ProjectAxis` transform which removes an existing axis."""
49
+
50
+ def check(self, ndim: int) -> int:
51
+ if self.idx >= ndim or self.idx <= -ndim:
52
+ raise ValueError(
53
+ f"Index {self.idx} is out of range for dimensionality {ndim}"
54
+ )
55
+ return ndim - 1
56
+
57
+ def invert(self) -> Insert:
58
+ if self.idx == -1:
59
+ raise ValueError("Removal of the -1th axis is not invertible")
60
+ return Insert(self.idx)
61
+
62
+
63
+ Operation = Insert | Remove
64
+ """Insert or remove an axis."""
65
+
66
+
67
+ class ProjectAxis(Transform):
68
+ """Transform for adding and removing axes.
69
+
70
+ WARNING: inverting this transformation may be lossy.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ operations: Sequence[Operation],
76
+ source_ndim: int | None = None,
77
+ target_ndim: int | None = None,
78
+ *,
79
+ spaces: Spaces = Spaces(None, None),
80
+ ):
81
+ """Create a transform for adding and dropping axes.
82
+
83
+ At least one of source_ndim and target_ndim must be given.
84
+
85
+ Parameters
86
+ ----------
87
+ operations
88
+ Sequence of operations to apply.
89
+ source_ndim
90
+ If omitted, can be inferred from `target_ndim`.
91
+ target_ndim
92
+ If omitted, can be inferred from `source_ndim`.
93
+ spaces
94
+ Identifiers for source and target spaces, by default Spaces(None, None)
95
+
96
+ Raises
97
+ ------
98
+ ValueError
99
+ Operations are inconsistent with given dimensionality,
100
+ or insufficient dimensionality information was given.
101
+ """
102
+ self.operations = []
103
+ self._has_inserts = False
104
+
105
+ if source_ndim is not None:
106
+ nd = source_ndim
107
+ for op in operations:
108
+ nd = op.check(nd)
109
+ if target_ndim is None:
110
+ target_ndim = nd
111
+ elif target_ndim != nd:
112
+ raise ValueError("Operations do not match expected target ndim")
113
+
114
+ elif target_ndim is not None:
115
+ nd = target_ndim
116
+ for op in reversed(operations):
117
+ nd = op.invert().check(nd)
118
+ if source_ndim is None:
119
+ source_ndim = nd
120
+ elif source_ndim != nd:
121
+ raise ValueError("Operations do not match expected source ndim")
122
+
123
+ else:
124
+ raise ValueError("At least one of source_ndim or target_ndim must be given")
125
+
126
+ idxs: list[int | None] = list(range(source_ndim))
127
+ for op in operations:
128
+ if isinstance(op, Insert):
129
+ self._has_inserts = True
130
+ idxs.insert(op.idx, None)
131
+ elif isinstance(op, Remove):
132
+ idxs.pop(op.idx)
133
+ self.operations.append(op)
134
+ self._idxs = idxs
135
+
136
+ super().__init__(NDims(source_ndim, target_ndim), spaces=spaces)
137
+
138
+ def apply(self, coords: ArrayT) -> ArrayT:
139
+ coords = self._validate_coords(coords)
140
+ if self._has_inserts:
141
+ xp = array_namespace(coords)
142
+ out = xp.zeros_like(coords, shape=(xp.shape(coords)[0], self.ndims.target))
143
+ for idx, orig_idx in enumerate(self._idxs):
144
+ if orig_idx is not None:
145
+ out[:, idx] = coords[:, orig_idx] # type:ignore
146
+
147
+ else:
148
+ out = coords[:, self._idxs] # type:ignore
149
+ return out
150
+
151
+ def is_identity(self) -> bool:
152
+ orig: list[int | None] = list(range(self.ndims.source))
153
+ dims = copy(orig)
154
+ for op in self.operations:
155
+ if isinstance(op, Insert):
156
+ dims.insert(op.idx, None)
157
+ elif isinstance(op, Remove):
158
+ dims.pop(op.idx)
159
+
160
+ return dims == orig
161
+
162
+ def to_affine(self) -> Affine | None:
163
+ m = np.eye(self.ndims.source)
164
+ out_m = self.apply(m)
165
+ return Affine.from_linear_map(out_m.T)
166
+
167
+ def invert(self) -> Self | None:
168
+ return type(self)(
169
+ [op.invert() for op in reversed(self.operations)],
170
+ source_ndim=self.ndims.target,
171
+ target_ndim=self.ndims.source,
172
+ spaces=self.spaces.invert(),
173
+ )
@@ -6,7 +6,6 @@ Requires the `thinplatesplines` extra.
6
6
 
7
7
  import logging
8
8
 
9
- import morphops as mops
10
9
  import numpy as np
11
10
 
12
11
  from ..base import Transform
@@ -20,6 +19,8 @@ class ThinPlateSplines(Transform[np.ndarray]):
20
19
  """Thin plate splines transforms.
21
20
 
22
21
  Deform based on matched pairs of control points.
22
+
23
+ REQUIRES: `thinplatesplines` extra.
23
24
  """
24
25
 
25
26
  def __init__(
@@ -48,6 +49,8 @@ class ThinPlateSplines(Transform[np.ndarray]):
48
49
  ValueError
49
50
  Invalid control points.
50
51
  """
52
+ import morphops
53
+
51
54
  self.source_control_points = as_floats(source_control_points)
52
55
  self.target_control_points = as_floats(target_control_points)
53
56
 
@@ -59,7 +62,7 @@ class ThinPlateSplines(Transform[np.ndarray]):
59
62
 
60
63
  ndim = self.source_control_points.shape[1]
61
64
 
62
- self.W, self.A = mops.tps_coefs(
65
+ self.W, self.A = morphops.tps_coefs(
63
66
  self.source_control_points,
64
67
  self.target_control_points,
65
68
  )
@@ -73,8 +76,10 @@ class ThinPlateSplines(Transform[np.ndarray]):
73
76
  )
74
77
 
75
78
  def apply(self, coords: np.ndarray) -> np.ndarray:
79
+ import morphops
80
+
76
81
  coords = self._validate_coords(coords)
77
- U = mops.K_matrix(coords, self.source_control_points)
78
- P = mops.P_matrix(coords)
82
+ U = morphops.K_matrix(coords, self.source_control_points)
83
+ P = morphops.P_matrix(coords)
79
84
  # The warped pts are the affine part + the non-uniform part
80
85
  return P @ self.A + U @ self.W
@@ -10,8 +10,6 @@ from ..types import NDims, Spaces
10
10
  from ..base import Transform, ArrayT
11
11
  from ..util import set_scipy_array_api, as_floats
12
12
 
13
- set_scipy_array_api()
14
-
15
13
  __all__ = ["Coordinates", "Displacements"]
16
14
 
17
15
 
@@ -95,6 +93,8 @@ class BaseVectorField(Transform[ArrayT], ABC):
95
93
 
96
94
  set_scipy_array_api()
97
95
  xp = array_namespace(index_coords_t)
96
+
97
+ # make columnar output array so that each dimension can be written contiguously
98
98
  out = xp.zeros_like(
99
99
  self.vector_field, shape=(self.ndims.target, xp.shape(index_coords_t)[1])
100
100
  )
@@ -133,6 +133,9 @@ class Coordinates(BaseVectorField[ArrayT]):
133
133
  the output coordinate is `vector_field[a, b, c, :].
134
134
 
135
135
  Input coordinates outside the vector field return NaN.
136
+
137
+ REQUIRES: `vectorfield` extra for in-memory,
138
+ or `vectorfield-dask` extra for lazy chunked vector fields.
136
139
  """
137
140
 
138
141
  def __init__(
@@ -182,6 +185,9 @@ class Displacements(BaseVectorField[ArrayT]):
182
185
  the output coordinate is `(a, b, c) + vector_field[a, b, c, :].
183
186
 
184
187
  Input coordinates outside the vector field return NaN.
188
+
189
+ REQUIRES: `vectorfield` extra for in-memory,
190
+ or `vectorfield-dask` extra for lazy chunked vector fields.
185
191
  """
186
192
 
187
193
  def __init__(
@@ -1,19 +0,0 @@
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
- ]