transformnd 0.7.2__tar.gz → 0.9.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 (33) hide show
  1. {transformnd-0.7.2 → transformnd-0.9.0}/PKG-INFO +8 -3
  2. {transformnd-0.7.2 → transformnd-0.9.0}/README.md +7 -2
  3. transformnd-0.9.0/pyproject.toml +125 -0
  4. transformnd-0.7.2/pyproject.toml → transformnd-0.9.0/pyproject.toml.orig +1 -1
  5. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/__init__.py +5 -5
  6. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/base.py +64 -112
  7. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/graph.py +136 -108
  8. transformnd-0.9.0/src/transformnd/spaced.py +67 -0
  9. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/__init__.py +2 -0
  10. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/affine.py +14 -56
  11. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/bijection.py +5 -15
  12. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/by_dimension.py +25 -8
  13. transformnd-0.9.0/src/transformnd/transforms/grid.py +47 -0
  14. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/map_axis.py +8 -10
  15. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/moving_least_squares.py +1 -7
  16. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/project_axis.py +13 -8
  17. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/reflection.py +4 -16
  18. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/simple.py +33 -30
  19. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/thinplate.py +2 -7
  20. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/transforms/vector_field.py +14 -18
  21. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/types.py +9 -11
  22. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/util.py +6 -9
  23. transformnd-0.7.2/src/transformnd/constants.py +0 -1
  24. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/__init__.py +0 -0
  25. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/base.py +0 -0
  26. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/bounding_box.py +0 -0
  27. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/pandas.py +0 -0
  28. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/polars.py +0 -0
  29. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/adapters/shapely.py +0 -0
  30. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/extents/__init__.py +0 -0
  31. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/extents/base.py +0 -0
  32. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/extents/bounding_box.py +0 -0
  33. {transformnd-0.7.2 → transformnd-0.9.0}/src/transformnd/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transformnd
3
- Version: 0.7.2
3
+ Version: 0.9.0
4
4
  Summary: ND coordinate transformations
5
5
  Author: Chris Barnes
6
6
  Author-email: Chris Barnes <chris.barnes@gerbi-gmb.de>
@@ -86,6 +86,11 @@ See the [tutorial here](https://github.com/clbarnes/transformnd/blob/main/exampl
86
86
  It is a [marimo](https://marimo.io) notebook.
87
87
  Open it with `uv run --group examples marimo edit examples/tutorial.py`.
88
88
 
89
+ ## Usage
90
+
91
+ `transformnd` is pre-1.0 and the API is subject to change.
92
+ Production users should pin the maximum dependency version at the next minor version, like `uv add --bounds minor`.
93
+
89
94
  ## Implemented transforms
90
95
 
91
96
  All transforms are accessed under the `transformnd.transforms` subpackage.
@@ -138,11 +143,11 @@ Methods which MUST be implemented:
138
143
 
139
144
  Methods which SHOULD be implemented if applicable:
140
145
 
141
- - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU)
146
+ - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU). The base class implementation returns `self`.
142
147
  - `is_identity`: if you can cheaply check whether your transformation is an identity transformation. The base class implementation returns `False`.
143
148
  - `to_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
144
149
  - `invert`: if your transformation can be inverted (default None if not)
145
- - This automatically implements `__invert__` (the `~my_transform` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
150
+ - This automatically implements `__invert__` (the `~` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
146
151
 
147
152
  ## Contributing
148
153
 
@@ -31,6 +31,11 @@ See the [tutorial here](https://github.com/clbarnes/transformnd/blob/main/exampl
31
31
  It is a [marimo](https://marimo.io) notebook.
32
32
  Open it with `uv run --group examples marimo edit examples/tutorial.py`.
33
33
 
34
+ ## Usage
35
+
36
+ `transformnd` is pre-1.0 and the API is subject to change.
37
+ Production users should pin the maximum dependency version at the next minor version, like `uv add --bounds minor`.
38
+
34
39
  ## Implemented transforms
35
40
 
36
41
  All transforms are accessed under the `transformnd.transforms` subpackage.
@@ -83,11 +88,11 @@ Methods which MUST be implemented:
83
88
 
84
89
  Methods which SHOULD be implemented if applicable:
85
90
 
86
- - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU)
91
+ - `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU). The base class implementation returns `self`.
87
92
  - `is_identity`: if you can cheaply check whether your transformation is an identity transformation. The base class implementation returns `False`.
88
93
  - `to_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`.
89
94
  - `invert`: if your transformation can be inverted (default None if not)
90
- - This automatically implements `__invert__` (the `~my_transform` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
95
+ - This automatically implements `__invert__` (the `~` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`.
91
96
 
92
97
  ## Contributing
93
98
 
@@ -0,0 +1,125 @@
1
+ [project]
2
+ name = "transformnd"
3
+ version = "0.9.0"
4
+ description = "ND coordinate transformations"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12, <4.0"
7
+ dependencies = [
8
+ "numpy>=2",
9
+ "networkx>=3",
10
+ "array_api_compat>=1.14",
11
+ "typing-extensions>=4.15.0",
12
+ ]
13
+ license = "MIT"
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Scientific/Engineering",
18
+ "Topic :: Scientific/Engineering :: GIS",
19
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
20
+ "Topic :: Scientific/Engineering :: Image Processing",
21
+ "Natural Language :: English",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ ]
27
+
28
+ [[project.authors]]
29
+ name = "Chris Barnes"
30
+ email = "chris.barnes@gerbi-gmb.de"
31
+
32
+ [project.urls]
33
+ documentation = "https://transformnd.readthedocs.io/en/latest/"
34
+ source = "https://github.com/clbarnes/transformnd"
35
+ issues = "https://github.com/clbarnes/transformnd/issues"
36
+
37
+ [project.optional-dependencies]
38
+ thinplatesplines = ["morphops>=0.1.13"]
39
+ movingleastsquares = ["molesq>=0.4.0"]
40
+ pandas = ["pandas>=3.0.2"]
41
+ shapely = ["shapely>=2.1.2"]
42
+ polars = ["polars>=1.40.1"]
43
+ vectorfield = ["scipy>=1.17.1"]
44
+ vectorfield-dask = [
45
+ "dask>=2026.3.0",
46
+ "dask-image>=2026.5.0",
47
+ ]
48
+ transforms = [
49
+ "transformnd[thinplatesplines]",
50
+ "transformnd[movingleastsquares]",
51
+ "transformnd[vectorfield]",
52
+ "transformnd[vectorfield-dask]",
53
+ ]
54
+ adapters = [
55
+ "transformnd[pandas]",
56
+ "transformnd[shapely]",
57
+ "transformnd[polars]",
58
+ ]
59
+ all = [
60
+ "transformnd[transforms]",
61
+ "transformnd[adapters]",
62
+ ]
63
+
64
+ [dependency-groups]
65
+ test = [
66
+ "pytest",
67
+ "jax",
68
+ "dask>=2026.3.0",
69
+ ]
70
+ lint = [
71
+ "ruff",
72
+ "mypy",
73
+ "prek>=0.3.9",
74
+ "types-shapely>=2.1.0.20260408",
75
+ "pydoclint>=0.8.6",
76
+ ]
77
+ doc = ["pdoc>=16.0.0"]
78
+ examples = [
79
+ "marimo>=0.9",
80
+ "matplotlib>=3.10.8",
81
+ "pandas>=3.0.2",
82
+ "pooch>=1.9.0",
83
+ "scikit-image>=0.26.0",
84
+ ]
85
+ bench = ["pytest-benchmark>=5.2.3"]
86
+
87
+ [[dependency-groups.dev]]
88
+ include-group = "test"
89
+
90
+ [[dependency-groups.dev]]
91
+ include-group = "lint"
92
+
93
+ [[dependency-groups.dev]]
94
+ include-group = "doc"
95
+
96
+ [[dependency-groups.dev]]
97
+ include-group = "examples"
98
+
99
+ [[dependency-groups.dev]]
100
+ include-group = "bench"
101
+
102
+ [build-system]
103
+ requires = ["uv_build>=0.11.0,<0.12.0"]
104
+ build-backend = "uv_build"
105
+
106
+ [tool.mypy]
107
+ ignore_missing_imports = true
108
+ check_untyped_defs = true
109
+
110
+ [tool.pytest]
111
+ testpaths = [
112
+ "tests",
113
+ "bench",
114
+ ]
115
+ addopts = ["--benchmark-skip"]
116
+
117
+ [tool.pydoclint]
118
+ arg-type-hints-in-docstring = false
119
+ allow-init-docstring = true
120
+
121
+ [tool.ruff.lint]
122
+ external = [
123
+ "DOC",
124
+ "D",
125
+ ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "transformnd"
3
- version = "0.7.2"
3
+ version = "0.9.0"
4
4
  description = "ND coordinate transformations"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Chris Barnes", email = "chris.barnes@gerbi-gmb.de" }]
@@ -8,12 +8,12 @@ You can find some examples here:
8
8
 
9
9
  """
10
10
 
11
- from .base import Transform, TransformSequence, TransformWrapper
12
- from .util import SpaceRef
13
- from .types import Spaces, TransformSignature, NDims
11
+ from .base import Transform, TransformSequence, TransformFnWrapper
12
+ from .types import TransformSignature, NDims, SpaceRef
14
13
  from . import transforms
15
14
  from . import adapters
16
15
  from .graph import TransformGraph
16
+ from .spaced import Spaced
17
17
  from importlib.metadata import version as _version
18
18
 
19
19
  __version__ = _version("transformnd")
@@ -22,11 +22,11 @@ __all__ = [
22
22
  "Transform",
23
23
  "TransformGraph",
24
24
  "TransformSequence",
25
- "TransformWrapper",
25
+ "TransformFnWrapper",
26
26
  "TransformSignature",
27
27
  "SpaceRef",
28
28
  "transforms",
29
29
  "adapters",
30
- "Spaces",
31
30
  "NDims",
31
+ "Spaced",
32
32
  ]
@@ -11,14 +11,12 @@ from types import ModuleType
11
11
  from array_api_compat import array_namespace
12
12
 
13
13
  from .util import (
14
- SpaceRef,
15
- same_or_none,
16
- space_str,
17
14
  ArrayT,
15
+ join_strs,
18
16
  )
19
17
  from itertools import pairwise
20
18
 
21
- from .types import TransformSignature, Spaces, NDims
19
+ from .types import TransformSignature, NDims
22
20
 
23
21
  if TYPE_CHECKING:
24
22
  from .transforms import Affine
@@ -30,19 +28,14 @@ class Transform[ArrayT](ABC):
30
28
  def __init__(
31
29
  self,
32
30
  ndims: NDims,
33
- *,
34
- spaces: Spaces = Spaces(None, None),
35
31
  ):
36
32
  """
37
33
  Parameters
38
34
  ----------
39
35
  ndims
40
36
  Source and target dimensionality.
41
- spaces
42
- Optional source and target spaces
43
37
  """
44
38
  self.ndims: NDims = ndims
45
- self.spaces: Spaces = spaces
46
39
 
47
40
  def is_identity(self) -> bool:
48
41
  """Whether this is a no-op transformation."""
@@ -105,18 +98,18 @@ class Transform[ArrayT](ABC):
105
98
  """
106
99
  pass
107
100
 
108
- def invert(self) -> Transform | None:
101
+ def invert(self) -> Transform[ArrayT] | None:
109
102
  """Invert the transformation, returning `None` if not possible."""
110
103
  return None
111
104
 
112
- def __invert__(self) -> Transform:
105
+ def __invert__(self) -> Transform[ArrayT]:
113
106
  """Invert transformation if possible.
114
107
 
115
108
  Returns `NotImplemented` otherwise (will raise `NotImplementedError`).
116
109
 
117
110
  Returns
118
111
  -------
119
- Transform
112
+ Transform[ArrayT]
120
113
  Inverted transformation.
121
114
  """
122
115
  t = self.invert()
@@ -141,10 +134,9 @@ class Transform[ArrayT](ABC):
141
134
  Returns
142
135
  -------
143
136
  Self
144
- A new transform instance with parameters on the target device,
145
- or NotImplemented if the subclass does not support device placement.
137
+ A new transform instance with parameters on the target device
146
138
  """
147
- return NotImplemented
139
+ return self
148
140
 
149
141
  def __or__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]:
150
142
  """Compose transformations into a sequence.
@@ -166,7 +158,6 @@ class Transform[ArrayT](ABC):
166
158
  transforms = as_transform_list(self) + as_transform_list(other)
167
159
  return TransformSequence[ArrayT](
168
160
  transforms,
169
- spaces=Spaces(self.spaces.source, other.spaces.target),
170
161
  )
171
162
 
172
163
  def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]:
@@ -189,17 +180,13 @@ class Transform[ArrayT](ABC):
189
180
  transforms = as_transform_list(other) + as_transform_list(self)
190
181
  return TransformSequence(
191
182
  transforms,
192
- spaces=Spaces(other.spaces.source, self.spaces.target),
193
183
  )
194
184
 
195
185
  def __str__(self) -> str:
196
- cls_name = type(self).__name__
197
- src = space_str(self.spaces.source)
198
- tgt = space_str(self.spaces.target)
199
- return f"{cls_name}[{src}->{tgt}]"
186
+ return f"{type(self).__qualname__}@{hex(id(self))}[{self.ndims}]"
200
187
 
201
188
 
202
- class TransformWrapper(Transform[ArrayT]):
189
+ class TransformFnWrapper(Transform[ArrayT]):
203
190
  """Wrapper around an arbitrary function which transforms coordinates."""
204
191
 
205
192
  def __init__(
@@ -207,8 +194,6 @@ class TransformWrapper(Transform[ArrayT]):
207
194
  fn: TransformSignature[ArrayT],
208
195
  in_ndim: int,
209
196
  out_ndim: int,
210
- *,
211
- spaces: Spaces = Spaces(None, None),
212
197
  ):
213
198
  """Wrapper around an arbitrary function.
214
199
 
@@ -223,46 +208,28 @@ class TransformWrapper(Transform[ArrayT]):
223
208
  Dimensionality of the input coordinates.
224
209
  out_ndim
225
210
  Dimensionality of the output coordinates.
226
- spaces
227
- Optional source and target spaces
228
211
  """
229
- super().__init__(NDims(in_ndim, out_ndim), spaces=spaces)
212
+ super().__init__(NDims(in_ndim, out_ndim))
230
213
  self.fn = fn
231
214
 
232
215
  def apply(self, coords: ArrayT) -> ArrayT:
233
216
  self._validate_coords(coords)
234
217
  return self.fn(coords)
235
218
 
219
+ def __str__(self) -> str:
220
+ return f"{super().__str__()}({self.fn})"
236
221
 
237
- def _with_spaces(
238
- t: Transform[ArrayT],
239
- source_space: SpaceRef | None = None,
240
- target_space: SpaceRef | None = None,
241
- ) -> Transform[ArrayT]:
242
- src_tgt = (t.spaces.source, t.spaces.target)
243
- src = same_or_none(src_tgt[0], source_space, default=None)
244
- tgt = same_or_none(src_tgt[1], target_space, default=None)
245
- if (src, tgt) != src_tgt:
246
- t = copy(t)
247
- t.spaces = Spaces(src, tgt)
248
- return t
249
-
250
-
251
- def infer_spaces(
252
- transforms: Sequence[Transform[ArrayT]], source_space=None, target_space=None
253
- ) -> list[Transform[ArrayT]]:
254
- prev_tgts = [source_space]
255
- next_srcs = []
256
- for t1, t2 in pairwise(transforms):
257
- prev_tgts.append(t1.spaces.target)
258
- next_srcs.append(t2.spaces.source)
222
+ @classmethod
223
+ def from_flat(cls, fn: TransformSignature[ArrayT]) -> Self:
224
+ """Create a 1D transform from a function which would take and return a 1D array."""
259
225
 
260
- next_srcs.append(target_space)
226
+ def fn2(arr: ArrayT) -> ArrayT:
227
+ xp = array_namespace(arr)
228
+ flat = xp.reshape(arr, (-1,))
229
+ transformed = fn(flat)
230
+ return xp.expand_dims(transformed, 1)
261
231
 
262
- out = []
263
- for t, next_src, prev_tgt in zip(transforms, next_srcs, prev_tgts):
264
- out.append(_with_spaces(t, prev_tgt, next_src))
265
- return out
232
+ return cls(fn2, 1, 1)
266
233
 
267
234
 
268
235
  def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]:
@@ -273,36 +240,39 @@ def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]:
273
240
 
274
241
 
275
242
  class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
276
- """Chain transforms, applying one after another."""
243
+ """Chain transforms, applying one after another.
244
+
245
+ The `TransformSequence()` constructor takes a sequence of transforms which must not be empty.
246
+ Empty sequences can be handled with `TransformSequence.empty(ndim: int)`.
247
+ """
277
248
 
278
249
  def __init__(
279
250
  self,
280
251
  transforms: Sequence[Transform[ArrayT]],
281
- *,
282
- spaces: Spaces = Spaces(None, None),
283
252
  ) -> None:
284
253
  """Combine transforms by chaining them.
285
254
 
286
- Also checks for consistent dimensionality and space references,
287
- inferring if None.
255
+ Empty sequences raise an error;
256
+ use the `TransformSequence.empty(ndim)` constructor instead.
257
+
258
+ Also checks for consistent dimensionality.
288
259
 
289
260
  Parameters
290
261
  ----------
291
262
  transforms :
292
263
  Items which are a TransformSequences
293
264
  will each still be treated as a single transform.
294
- spaces :
295
- Optional source and target spaces.
296
- Can also be inferred from the first and last transforms.
297
265
 
298
266
  Raises
299
267
  ------
300
268
  ValueError
301
- If spaces are incompatible.
269
+ If spaces are incompatible, or no spaces are given.
302
270
  """
303
- ts = infer_spaces(transforms, *spaces)
271
+ ts = list(transforms)
304
272
  if not ts:
305
- raise ValueError("Empty transform sequence")
273
+ raise ValueError(
274
+ "Empty transform sequence; use TransformSequence.empty(ndim)"
275
+ )
306
276
 
307
277
  for idx, (t1, t2) in enumerate(pairwise(ts)):
308
278
  if t1.ndims.target != t2.ndims.source:
@@ -312,16 +282,20 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
312
282
  f"and the next source is {t2.ndims.source}D"
313
283
  )
314
284
 
315
- spaces = Spaces(ts[0].spaces.source, ts[-1].spaces.target)
316
285
  ndims = NDims(ts[0].ndims.source, ts[-1].ndims.target)
317
286
 
318
- super().__init__(
319
- ndims,
320
- spaces=spaces,
321
- )
287
+ super().__init__(ndims)
322
288
 
323
289
  self.transforms: list[Transform[ArrayT]] = ts
324
290
 
291
+ @classmethod
292
+ def empty(cls, ndim: int) -> Self:
293
+ from .transforms import Identity
294
+
295
+ out = cls([Identity(ndim)])
296
+ out.transforms.pop()
297
+ return out
298
+
325
299
  def __iter__(self) -> Iterator[Transform[ArrayT]]:
326
300
  """Iterate through component transforms.
327
301
 
@@ -341,13 +315,15 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
341
315
  return len(self.transforms)
342
316
 
343
317
  def invert(self) -> Transform[ArrayT] | None:
318
+ if self.is_empty():
319
+ return type(self).empty(self.ndims.source)
320
+
344
321
  try:
345
322
  transforms = [~t for t in reversed(self.transforms)]
346
323
  except NotImplementedError:
347
324
  return None
348
325
  return type(self)(
349
326
  transforms,
350
- spaces=self.spaces.invert(),
351
327
  )
352
328
 
353
329
  def apply(self, coords: ArrayT) -> ArrayT:
@@ -357,45 +333,13 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
357
333
 
358
334
  def to_device(self, xp: ModuleType, device: str | None = None) -> Self:
359
335
  result = copy(self)
360
- result.transforms = [t.to_device(xp, device) for t in self.transforms]
336
+ if not self.is_empty():
337
+ result.transforms = [t.to_device(xp, device) for t in self.transforms]
361
338
  return result
362
339
 
363
- def list_spaces(self, skip_none: bool = False) -> list[SpaceRef]:
364
- """List spaces in this transform.
365
-
366
- Parameters
367
- ----------
368
- skip_none
369
- Whether to skip undefined spaces, default False.
370
-
371
- Returns
372
- -------
373
- list[SpaceRef]
374
- The list of spaces.
375
- """
376
- spaces = [self.spaces.source] + [t.spaces.target for t in self.transforms]
377
- if skip_none:
378
- spaces = [s for s in spaces if s is not None]
379
- return spaces
380
-
381
- def split(self) -> Iterator[Transform[ArrayT]]:
382
- """Split the sequence where an intermediate space is known."""
383
- this_seq = []
384
-
385
- for t in self.transforms:
386
- if t.spaces.source is not None and t.spaces.target is not None:
387
- yield t
388
- continue
389
-
390
- this_seq.append(t)
391
- if t.spaces.target is not None:
392
- yield type(self)(this_seq)
393
- this_seq = []
394
-
395
340
  def __str__(self) -> str:
396
- cls_name = type(self).__name__
397
- spaces_str = "->".join(space_str(s) for s in self.list_spaces())
398
- return f"{cls_name}[{spaces_str}]"
341
+ spaces_str = join_strs(self.transforms, "|")
342
+ return f"{super().__str__()}({spaces_str})"
399
343
 
400
344
  def __getitem__(self, idx: slice | int):
401
345
  if isinstance(idx, int):
@@ -405,10 +349,16 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
405
349
  def is_identity(self) -> bool:
406
350
  return all(t.is_identity() for t in self)
407
351
 
352
+ def is_empty(self) -> bool:
353
+ return not self.transforms
354
+
408
355
  def flatten(self, drop_inverse: bool = True) -> Self:
409
356
  """Flatten nested sequences."""
410
357
  from .transforms.bijection import Bijection
411
358
 
359
+ if self.is_empty():
360
+ return copy(self)
361
+
412
362
  out: list[Transform[ArrayT]] = []
413
363
 
414
364
  for t in self.transforms:
@@ -418,7 +368,7 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
418
368
  out.extend(t.flatten())
419
369
  else:
420
370
  out.append(t)
421
- return TransformSequence(out, spaces=self.spaces) # type:ignore
371
+ return TransformSequence(out) # type:ignore
422
372
 
423
373
  def simplify(self, drop_inverse: bool = True):
424
374
  """Reduce the number of transformations in this sequence if possible.
@@ -433,7 +383,8 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
433
383
  Does not check whether transforms invert each other,
434
384
  e.g. `Translation(1) | Translation(-1)`.
435
385
  """
436
- from .transforms import Identity
386
+ if self.is_empty():
387
+ return copy(self)
437
388
 
438
389
  out: list[Transform[ArrayT]] = []
439
390
  affine = None
@@ -459,11 +410,13 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]):
459
410
  add_to_output(affine, out)
460
411
 
461
412
  if not out:
462
- out.append(Identity(self.ndims.source))
413
+ return type(self).empty(self.ndims.source)
463
414
 
464
- return type(self)(out, spaces=self.spaces)
415
+ return type(self)(out)
465
416
 
466
417
  def to_affine(self) -> Affine[ArrayT] | None:
418
+ if self.is_empty():
419
+ return Affine.identity(self.ndims.source) # type:ignore
467
420
  simple = self.simplify(True)
468
421
  if len(simple) != 1:
469
422
  return None
@@ -475,6 +428,5 @@ def add_to_output(transform: Transform, lst: list[Transform]) -> bool:
475
428
  return False
476
429
 
477
430
  transform = copy(transform)
478
- transform.spaces = Spaces(None, None)
479
431
  lst.append(transform)
480
432
  return True