transformnd 0.1.0__tar.gz → 0.2.1__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 (28) hide show
  1. {transformnd-0.1.0 → transformnd-0.2.1}/PKG-INFO +26 -13
  2. {transformnd-0.1.0 → transformnd-0.2.1}/README.md +11 -12
  3. {transformnd-0.1.0 → transformnd-0.2.1}/pyproject.toml +19 -1
  4. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/__init__.py +4 -2
  5. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/base.py +58 -72
  6. transformnd-0.2.1/src/transformnd/constants.py +1 -0
  7. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/extents/base.py +2 -1
  8. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/extents/bounding_box.py +9 -3
  9. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/graph.py +87 -28
  10. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/affine.py +68 -76
  11. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/bijection.py +9 -13
  12. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/by_dimension.py +16 -18
  13. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/map_axis.py +9 -13
  14. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/moving_least_squares.py +14 -9
  15. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/reflection.py +13 -5
  16. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/simple.py +36 -59
  17. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/thinplate.py +8 -11
  18. transformnd-0.2.1/src/transformnd/types.py +46 -0
  19. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/util.py +12 -101
  20. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/__init__.py +0 -0
  21. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/base.py +0 -0
  22. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/bounding_box.py +0 -0
  23. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/pandas.py +0 -0
  24. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/polars.py +0 -0
  25. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/adapters/shapely.py +0 -0
  26. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/extents/__init__.py +0 -0
  27. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/py.typed +0 -0
  28. {transformnd-0.1.0 → transformnd-0.2.1}/src/transformnd/transforms/__init__.py +0 -0
@@ -1,10 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transformnd
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: ND coordinate transformations
5
5
  Author: Chris Barnes
6
6
  Author-email: Chris Barnes <chris.barnes@gerbi-gmb.de>
7
7
  License-Expression: MIT
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Classifier: Topic :: Scientific/Engineering :: GIS
12
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
13
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
14
+ Classifier: Natural Language :: English
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
8
19
  Requires-Dist: numpy>=2
9
20
  Requires-Dist: networkx>=3
10
21
  Requires-Dist: array-api-compat>=1.14
@@ -15,6 +26,9 @@ Requires-Dist: polars>=1.40.1 ; extra == 'polars'
15
26
  Requires-Dist: shapely>=2.1.2 ; extra == 'shapely'
16
27
  Requires-Dist: morphops>=0.1.13 ; extra == 'thinplatesplines'
17
28
  Requires-Python: >=3.12, <4.0
29
+ Project-URL: documentation, https://transformnd.readthedocs.io/en/latest/
30
+ Project-URL: source, https://github.com/clbarnes/transformnd
31
+ Project-URL: issues, https://github.com/clbarnes/transformnd/issues
18
32
  Provides-Extra: movingleastsquares
19
33
  Provides-Extra: pandas
20
34
  Provides-Extra: polars
@@ -24,10 +38,10 @@ Description-Content-Type: text/markdown
24
38
 
25
39
  # transformnd
26
40
 
27
- [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
28
- [![GitHub](https://img.shields.io/github/license/clbarnes/transformnd)](https://github.com/clbarnes/transformnd/blob/main/LICENSE)
29
- [![Test Status](https://img.shields.io/github/workflow/status/clbarnes/transformnd/ci)](https://github.com/clbarnes/transformnd/actions/workflows/ci.yaml)
30
- [![Docs Status](https://img.shields.io/github/workflow/status/clbarnes/transformnd/docs?label=docs)](https://clbarnes.github.io/transformnd/)
41
+ [![PyPI - Version](https://img.shields.io/pypi/v/transformnd)](https://pypi.org/project/transformnd/)
42
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/clbarnes/transformnd/ci.yaml)](https://github.com/clbarnes/transformnd/actions)
43
+ [![Read the Docs](https://img.shields.io/readthedocs/transformnd)](https://transformnd.readthedocs.io)
44
+ ![PyPI - License](https://img.shields.io/pypi/l/transformnd)
31
45
 
32
46
  A library providing an API for coordinate transformations,
33
47
  as well as some common transforms.
@@ -45,7 +59,7 @@ co-developed with [xform](https://github.com/schlegelp/xform/) as a red team pro
45
59
  `Transform` subclasses which are restricted to certain dimensionalities
46
60
  can specify this in their `ndim` class variable.
47
61
  Instances of `Transform` subclasses can further restrict their `ndim`.
48
- Use `self._validate_coords(coords)` in `__call__` to ensure the coordinates
62
+ Use `self._validate_coords(coords)` in the `apply` method to ensure the coordinates
49
63
  are of valid type and dimensions.
50
64
 
51
65
  Additionally, `transformnd` provides an interface for transforming types other than NxD numpy arrays,
@@ -91,27 +105,25 @@ Contributions of additional transforms and adapters are welcome!
91
105
  Even if they're only thin wrappers around an external library,
92
106
  the downstream ecosystem benefits from a consistent API.
93
107
 
94
- Such external transformation libraries should be specified as "extras",
108
+ Such external transformation libraries should be specified as "extras" (`pyproject.toml:project.optional-dependencies`),
95
109
  and be contained in a submodule so that they are not immediately imported
96
110
  with `transformnd`.
97
- Dependencies for new adapters do not need to be included in `transformnd`'s dependencies,
98
- but should be specified in the `requirements.txt` for tests.
99
111
 
100
112
  Alternatively, consider adopting `transformnd`'s base classes in your own library,
101
113
  and have your transformation instantly compatible for downstream users.
102
114
 
103
115
  Methods which MUST be implemented:
104
116
 
105
- - `__init__`: should validate parameters and set `self.ndim` if the parameters constrain the dimensionality
117
+ - `__init__`: should validate parameters and must call the `super()` constructor
106
118
  - `apply`: should call `_validate_coords` method early to check that the given coordinates are the correct shape
107
119
 
108
120
  Methods which SHOULD be implemented if applicable:
109
121
 
110
122
  - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU)
111
123
  - `is_identity`: if you can cheaply check whether your transformation is an identity transformation. The base class implementation returns `False`.
112
- - `into_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
124
+ - `to_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
113
125
  - `invert`: if your transformation can be inverted (default None if not)
114
- - This automatically implements `__invert__` (the `~my_transform` operator), which raises NotImplemented if `invert` would return `None`.
126
+ - This automatically implements `__invert__` (the `~my_transform` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
115
127
 
116
128
  ## Contributing
117
129
 
@@ -121,7 +133,8 @@ Methods which SHOULD be implemented if applicable:
121
133
  - `prek install-hooks && prek run --all-files` to get started.
122
134
  - Use [`just`](https://github.com/casey/just) for common development tasks (format, lint, test, generate docs, run benchmarks).
123
135
  - `just` to list commands.
124
- - Docs are generated with `pdoc` (use `just doc`) and hosted on ReadTheDocs
136
+ - Docs are generated with [`pdoc`](https://pdoc.dev/) (use `just doc`) and hosted on ReadTheDocs
137
+ - `just bump` bumps the version, commits, and tags (but does not push); depends on [`schpet/changelog`](https://github.com/schpet/changelog)
125
138
 
126
139
  ## Thanks
127
140
 
@@ -1,9 +1,9 @@
1
1
  # transformnd
2
2
 
3
- [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
4
- [![GitHub](https://img.shields.io/github/license/clbarnes/transformnd)](https://github.com/clbarnes/transformnd/blob/main/LICENSE)
5
- [![Test Status](https://img.shields.io/github/workflow/status/clbarnes/transformnd/ci)](https://github.com/clbarnes/transformnd/actions/workflows/ci.yaml)
6
- [![Docs Status](https://img.shields.io/github/workflow/status/clbarnes/transformnd/docs?label=docs)](https://clbarnes.github.io/transformnd/)
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/transformnd)](https://pypi.org/project/transformnd/)
4
+ [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/clbarnes/transformnd/ci.yaml)](https://github.com/clbarnes/transformnd/actions)
5
+ [![Read the Docs](https://img.shields.io/readthedocs/transformnd)](https://transformnd.readthedocs.io)
6
+ ![PyPI - License](https://img.shields.io/pypi/l/transformnd)
7
7
 
8
8
  A library providing an API for coordinate transformations,
9
9
  as well as some common transforms.
@@ -21,7 +21,7 @@ co-developed with [xform](https://github.com/schlegelp/xform/) as a red team pro
21
21
  `Transform` subclasses which are restricted to certain dimensionalities
22
22
  can specify this in their `ndim` class variable.
23
23
  Instances of `Transform` subclasses can further restrict their `ndim`.
24
- Use `self._validate_coords(coords)` in `__call__` to ensure the coordinates
24
+ Use `self._validate_coords(coords)` in the `apply` method to ensure the coordinates
25
25
  are of valid type and dimensions.
26
26
 
27
27
  Additionally, `transformnd` provides an interface for transforming types other than NxD numpy arrays,
@@ -67,27 +67,25 @@ Contributions of additional transforms and adapters are welcome!
67
67
  Even if they're only thin wrappers around an external library,
68
68
  the downstream ecosystem benefits from a consistent API.
69
69
 
70
- Such external transformation libraries should be specified as "extras",
70
+ Such external transformation libraries should be specified as "extras" (`pyproject.toml:project.optional-dependencies`),
71
71
  and be contained in a submodule so that they are not immediately imported
72
72
  with `transformnd`.
73
- Dependencies for new adapters do not need to be included in `transformnd`'s dependencies,
74
- but should be specified in the `requirements.txt` for tests.
75
73
 
76
74
  Alternatively, consider adopting `transformnd`'s base classes in your own library,
77
75
  and have your transformation instantly compatible for downstream users.
78
76
 
79
77
  Methods which MUST be implemented:
80
78
 
81
- - `__init__`: should validate parameters and set `self.ndim` if the parameters constrain the dimensionality
79
+ - `__init__`: should validate parameters and must call the `super()` constructor
82
80
  - `apply`: should call `_validate_coords` method early to check that the given coordinates are the correct shape
83
81
 
84
82
  Methods which SHOULD be implemented if applicable:
85
83
 
86
84
  - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU)
87
85
  - `is_identity`: if you can cheaply check whether your transformation is an identity transformation. The base class implementation returns `False`.
88
- - `into_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
86
+ - `to_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
89
87
  - `invert`: if your transformation can be inverted (default None if not)
90
- - This automatically implements `__invert__` (the `~my_transform` operator), which raises NotImplemented if `invert` would return `None`.
88
+ - This automatically implements `__invert__` (the `~my_transform` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
91
89
 
92
90
  ## Contributing
93
91
 
@@ -97,7 +95,8 @@ Methods which SHOULD be implemented if applicable:
97
95
  - `prek install-hooks && prek run --all-files` to get started.
98
96
  - Use [`just`](https://github.com/casey/just) for common development tasks (format, lint, test, generate docs, run benchmarks).
99
97
  - `just` to list commands.
100
- - Docs are generated with `pdoc` (use `just doc`) and hosted on ReadTheDocs
98
+ - Docs are generated with [`pdoc`](https://pdoc.dev/) (use `just doc`) and hosted on ReadTheDocs
99
+ - `just bump` bumps the version, commits, and tags (but does not push); depends on [`schpet/changelog`](https://github.com/schpet/changelog)
101
100
 
102
101
  ## Thanks
103
102
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transformnd"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "ND coordinate transformations"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Chris Barnes", email = "chris.barnes@gerbi-gmb.de" }]
@@ -12,6 +12,24 @@ dependencies = [
12
12
  "typing-extensions>=4.15.0",
13
13
  ]
14
14
  license = "MIT"
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Scientific/Engineering",
19
+ "Topic :: Scientific/Engineering :: GIS",
20
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
21
+ "Topic :: Scientific/Engineering :: Image Processing",
22
+ "Natural Language :: English",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ ]
28
+
29
+ [project.urls]
30
+ documentation = "https://transformnd.readthedocs.io/en/latest/"
31
+ source = "https://github.com/clbarnes/transformnd"
32
+ issues = "https://github.com/clbarnes/transformnd/issues"
15
33
 
16
34
  [project.optional-dependencies]
17
35
  thinplatesplines = [
@@ -3,7 +3,8 @@
3
3
  """
4
4
 
5
5
  from .base import Transform, TransformSequence, TransformWrapper
6
- from .util import SpaceRef, TransformSignature, check_ndim
6
+ from .util import SpaceRef
7
+ from .types import Spaces, TransformSignature, NDims
7
8
  from . import transforms
8
9
  from . import adapters
9
10
  from .graph import TransformGraph
@@ -18,7 +19,8 @@ __all__ = [
18
19
  "TransformWrapper",
19
20
  "TransformSignature",
20
21
  "SpaceRef",
21
- "check_ndim",
22
22
  "transforms",
23
23
  "adapters",
24
+ "Spaces",
25
+ "NDims",
24
26
  ]
@@ -11,17 +11,13 @@ from array_api_compat import array_namespace
11
11
 
12
12
  from .util import (
13
13
  SpaceRef,
14
- TransformSignature,
15
- check_ndim,
16
- dim_intersection,
17
- invert_spaces,
18
14
  same_or_none,
19
15
  space_str,
20
- to_single_ndim,
21
- window,
22
- SpaceTuple,
23
16
  ArrayT,
24
17
  )
18
+ from itertools import pairwise
19
+
20
+ from .types import TransformSignature, Spaces, NDims
25
21
 
26
22
  if TYPE_CHECKING:
27
23
  from .transforms import Affine
@@ -30,41 +26,28 @@ if TYPE_CHECKING:
30
26
  class Transform[ArrayT](ABC):
31
27
  """Base class for transforms."""
32
28
 
33
- ndim: set[int] | None = None
34
-
35
29
  def __init__(
36
30
  self,
31
+ ndims: NDims,
37
32
  *,
38
- spaces: SpaceTuple = (None, None),
33
+ spaces: Spaces = Spaces(None, None),
39
34
  ):
40
35
  """
41
36
  Parameters
42
37
  ----------
43
- spaces : tuple[SpaceRef, SpaceRef]
38
+ spaces
44
39
  Optional source and target spaces
45
40
  """
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]
41
+ self.ndims: NDims = ndims
42
+ self.spaces: Spaces = spaces
55
43
 
56
44
  def is_identity(self) -> bool:
57
45
  """Whether this is a no-op transformation."""
58
46
  return False
59
47
 
60
- def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
48
+ def to_affine(self) -> Affine[ArrayT] | None:
61
49
  """Convert the transform into affine, if conversion is possible.
62
50
 
63
- Parameters
64
- ----------
65
- dim: int, optional
66
- Total number of dimensions; If None, dim is set equal to self.ndim.
67
-
68
51
  Returns
69
52
  -------
70
53
  Transform | None:
@@ -74,9 +57,9 @@ class Transform[ArrayT](ABC):
74
57
  return None
75
58
 
76
59
  def _validate_coords(self, coords: ArrayT) -> ArrayT:
77
- """Check that dimension of coords are supported.
60
+ """Check that input coordinates are of the correct shape.
78
61
 
79
- Also ensure that coords is a 2D array.
62
+ Also ensure that coords is a 2Darray.
80
63
 
81
64
  Parameters
82
65
  ----------
@@ -91,7 +74,11 @@ class Transform[ArrayT](ABC):
91
74
  xp = array_namespace(coords)
92
75
  if xp.ndim(coords) != 2:
93
76
  raise ValueError("Coords must be a 2D array")
94
- check_ndim(xp.shape(coords)[1], self.ndim)
77
+ dim = xp.shape(coords)[1]
78
+ if xp.shape(coords)[1] != self.ndims.source:
79
+ raise ValueError(
80
+ f"Coords must have dimensionality {self.ndims.source}, got {dim}"
81
+ )
95
82
  return coords
96
83
 
97
84
  @abstractmethod
@@ -112,8 +99,6 @@ class Transform[ArrayT](ABC):
112
99
 
113
100
  def invert(self) -> Transform | None:
114
101
  """Invert the transformation, returning `None` if not possible."""
115
- if self.is_identity():
116
- return copy(self)
117
102
  return None
118
103
 
119
104
  def __invert__(self) -> Transform:
@@ -168,10 +153,10 @@ class Transform[ArrayT](ABC):
168
153
  """
169
154
  if not isinstance(other, Transform):
170
155
  return NotImplemented
171
- transforms = get_transform_list(self) + get_transform_list(other)
156
+ transforms = as_transform_list(self) + as_transform_list(other)
172
157
  return TransformSequence[ArrayT](
173
158
  transforms,
174
- spaces=(self.source_space, other.target_space),
159
+ spaces=Spaces(self.spaces.source, other.spaces.target),
175
160
  )
176
161
 
177
162
  def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]:
@@ -189,16 +174,16 @@ class Transform[ArrayT](ABC):
189
174
  """
190
175
  if not isinstance(other, Transform):
191
176
  return NotImplemented
192
- transforms = get_transform_list(other) + get_transform_list(self)
177
+ transforms = as_transform_list(other) + as_transform_list(self)
193
178
  return TransformSequence(
194
179
  transforms,
195
- spaces=(other.source_space, self.target_space),
180
+ spaces=Spaces(other.spaces.source, self.spaces.target),
196
181
  )
197
182
 
198
183
  def __str__(self) -> str:
199
184
  cls_name = type(self).__name__
200
- src = space_str(self.source_space)
201
- tgt = space_str(self.target_space)
185
+ src = space_str(self.spaces.source)
186
+ tgt = space_str(self.spaces.target)
202
187
  return f"{cls_name}[{src}->{tgt}]"
203
188
 
204
189
 
@@ -208,9 +193,10 @@ class TransformWrapper(Transform[ArrayT]):
208
193
  def __init__(
209
194
  self,
210
195
  fn: TransformSignature[ArrayT],
211
- ndim: set[int] | int | None = None,
196
+ in_ndim: int,
197
+ out_ndim: int,
212
198
  *,
213
- spaces: SpaceTuple = (None, None),
199
+ spaces: Spaces = Spaces(None, None),
214
200
  ):
215
201
  """Wrapper around an arbitrary function.
216
202
 
@@ -224,13 +210,8 @@ class TransformWrapper(Transform[ArrayT]):
224
210
  spaces : tuple[SpaceRef, SpaceRef]
225
211
  Optional source and target spaces
226
212
  """
227
- super().__init__(spaces=spaces)
213
+ super().__init__(NDims(in_ndim, out_ndim), spaces=spaces)
228
214
  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
215
 
235
216
  def apply(self, coords: ArrayT) -> ArrayT:
236
217
  self._validate_coords(coords)
@@ -242,12 +223,12 @@ def _with_spaces(
242
223
  source_space: SpaceRef | None = None,
243
224
  target_space: SpaceRef | None = None,
244
225
  ) -> Transform[ArrayT]:
245
- src_tgt = (t.source_space, t.target_space)
226
+ src_tgt = (t.spaces.source, t.spaces.target)
246
227
  src = same_or_none(src_tgt[0], source_space, default=None)
247
228
  tgt = same_or_none(src_tgt[1], target_space, default=None)
248
229
  if (src, tgt) != src_tgt:
249
230
  t = copy(t)
250
- t.spaces = (src, tgt)
231
+ t.spaces = Spaces(src, tgt)
251
232
  return t
252
233
 
253
234
 
@@ -256,9 +237,9 @@ def infer_spaces(
256
237
  ) -> list[Transform[ArrayT]]:
257
238
  prev_tgts = [source_space]
258
239
  next_srcs = []
259
- for t1, t2 in window(transforms, 2):
260
- prev_tgts.append(t1.target_space)
261
- next_srcs.append(t2.source_space)
240
+ for t1, t2 in pairwise(transforms):
241
+ prev_tgts.append(t1.spaces.target)
242
+ next_srcs.append(t2.spaces.source)
262
243
 
263
244
  next_srcs.append(target_space)
264
245
 
@@ -268,7 +249,7 @@ def infer_spaces(
268
249
  return out
269
250
 
270
251
 
271
- def get_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]:
252
+ def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]:
272
253
  if isinstance(t, TransformSequence):
273
254
  return t.transforms.copy()
274
255
  else:
@@ -282,7 +263,7 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
282
263
  self,
283
264
  transforms: Sequence[Transform[ArrayT]],
284
265
  *,
285
- spaces: SpaceTuple = (None, None),
266
+ spaces: Spaces = Spaces(None, None),
286
267
  ) -> None:
287
268
  """Combine transforms by chaining them.
288
269
 
@@ -291,10 +272,10 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
291
272
 
292
273
  Parameters
293
274
  ----------
294
- transforms : List[Transform[ArrayT]]
275
+ transforms :
295
276
  Items which are a TransformSequences
296
277
  will each still be treated as a single transform.
297
- spaces : tuple[SpaceRef, SpaceRef]
278
+ spaces :
298
279
  Optional source and target spaces.
299
280
  Can also be inferred from the first and last transforms.
300
281
 
@@ -304,23 +285,19 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
304
285
  If spaces are incompatible.
305
286
  """
306
287
  ts = infer_spaces(transforms, *spaces)
288
+ if not ts:
289
+ raise ValueError("Empty transform sequence")
290
+
291
+ spaces = Spaces(ts[0].spaces.source, ts[-1].spaces.target)
292
+ ndims = NDims(ts[0].ndims.source, ts[-1].ndims.target)
307
293
 
308
294
  super().__init__(
309
- spaces=(
310
- ts[0].source_space,
311
- ts[-1].target_space,
312
- ),
295
+ ndims,
296
+ spaces=spaces,
313
297
  )
314
298
 
315
299
  self.transforms: list[Transform[ArrayT]] = ts
316
300
 
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
301
  def __iter__(self) -> Iterator[Transform[ArrayT]]:
325
302
  """Iterate through component transforms.
326
303
 
@@ -346,7 +323,7 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
346
323
  return None
347
324
  return type(self)(
348
325
  transforms,
349
- spaces=invert_spaces(self.spaces),
326
+ spaces=self.spaces.invert(),
350
327
  )
351
328
 
352
329
  def apply(self, coords: ArrayT) -> ArrayT:
@@ -371,7 +348,7 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
371
348
  -------
372
349
  List[SpaceRef]
373
350
  """
374
- spaces = [self.source_space] + [t.target_space for t in self.transforms]
351
+ spaces = [self.spaces.source] + [t.spaces.target for t in self.transforms]
375
352
  if skip_none:
376
353
  spaces = [s for s in spaces if s is not None]
377
354
  return spaces
@@ -389,7 +366,7 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
389
366
  def is_identity(self) -> bool:
390
367
  return all(t.is_identity() for t in self)
391
368
 
392
- def simplify(self, ndim: int | None = None, drop_inverse: bool = False):
369
+ def simplify(self, drop_inverse: bool = True):
393
370
  """Reduce the number of transformations in this sequence if possible.
394
371
 
395
372
  - Compose consecutive transformations which can be expressed as affines
@@ -403,15 +380,15 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
403
380
  e.g. `Translation(1) | Translation(-1)`.
404
381
  """
405
382
  from .transforms.bijection import Bijection
383
+ from .transforms import Identity
406
384
 
407
- ndim = to_single_ndim(ndim, self.ndim)
408
385
  out: list[Transform[ArrayT]] = []
409
386
  affine = None
410
387
  for t in self.transforms:
411
388
  if drop_inverse and isinstance(t, Bijection):
412
389
  t = t.forward
413
390
 
414
- new_affine = t.to_affine(ndim)
391
+ new_affine = t.to_affine()
415
392
 
416
393
  if new_affine is None:
417
394
  if affine is not None:
@@ -428,7 +405,16 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
428
405
  if affine is not None:
429
406
  add_to_output(affine, out)
430
407
 
431
- return type(self)(out)
408
+ if not out:
409
+ out.append(Identity(self.ndims.source))
410
+
411
+ return type(self)(out, spaces=self.spaces)
412
+
413
+ def to_affine(self) -> Affine[ArrayT] | None:
414
+ simple = self.simplify(True)
415
+ if len(simple) != 1:
416
+ return None
417
+ return simple[0].to_affine()
432
418
 
433
419
 
434
420
  def add_to_output(transform: Transform, lst: list[Transform]) -> bool:
@@ -436,6 +422,6 @@ def add_to_output(transform: Transform, lst: list[Transform]) -> bool:
436
422
  return False
437
423
 
438
424
  transform = copy(transform)
439
- transform.spaces = (None, None)
425
+ transform.spaces = Spaces(None, None)
440
426
  lst.append(transform)
441
427
  return True
@@ -0,0 +1 @@
1
+ UNSPECIFIED_SPACE_NAME = "???"
@@ -4,7 +4,8 @@ from abc import ABC, abstractmethod
4
4
  class Extents[ArrayT](ABC):
5
5
  """Base class for determining whether coordinates are "inside" a space."""
6
6
 
7
- ndim: set[int] | None = None
7
+ def __init__(self, ndim: int) -> None:
8
+ self.ndim = ndim
8
9
 
9
10
  @abstractmethod
10
11
  def contains(self, coords: ArrayT) -> ArrayT: ...
@@ -1,6 +1,6 @@
1
1
  from functools import lru_cache
2
2
  from array_api_compat import array_namespace
3
- from ..util import ArrayT, are_coords
3
+ from ..util import ArrayT
4
4
  from .base import Extents
5
5
  from array_api_compat import device as xp_device
6
6
 
@@ -13,9 +13,9 @@ class BoundingBox(Extents[ArrayT]):
13
13
  if len(xp.shape(mins)) != 1:
14
14
  raise ValueError("mins and maxes must be 1D")
15
15
 
16
- self.ndim = {xp.shape(mins)[0]}
17
16
  self.mins = mins
18
17
  self.maxes = maxes
18
+ super().__init__(xp.shape(mins)[0])
19
19
 
20
20
  @lru_cache()
21
21
  def extents_cast(self, namespace, device) -> tuple[ArrayT, ArrayT]:
@@ -25,7 +25,13 @@ class BoundingBox(Extents[ArrayT]):
25
25
  )
26
26
 
27
27
  def _validate_coords(self, coords: ArrayT) -> ArrayT:
28
- return are_coords(coords, self.ndim)
28
+ xp = array_namespace(coords)
29
+ if xp.ndim(coords) != 2:
30
+ raise ValueError("Coords must be a 2D array")
31
+ dim = xp.shape(coords)[1]
32
+ if xp.shape(coords)[1] != self.ndim:
33
+ raise ValueError(f"Coords must have dimensionality {self.ndim}, got {dim}")
34
+ return coords
29
35
 
30
36
  def contains(self, coords: ArrayT) -> ArrayT:
31
37
  coords = self._validate_coords(coords)