diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cb848..16a3a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Fixed + +- `is_identity()` implementation for Identity, Translate, Scale + +### Changed + +- `TransformSequence` can now be empty with `.empty(ndim)` constructor +- `TransformGraph.get_sequence` can now handle identity transforms (i.e. space A to space A) [issues/86](https://github.com/clbarnes/transformnd/issues/86) + ## 0.8.0 - 2026-08-28 ### Added diff --git a/justfile b/justfile index c004cfb..172a718 100644 --- a/justfile +++ b/justfile @@ -36,9 +36,11 @@ format: test: uv run --all-groups --all-extras pytest -v +# Run marimo server for editing examples. example-edit example: uv run --group examples marimo edit examples/{{example}}.py +# Run marimo examples headless. example-test: uv run --group examples marimo export session examples --force-overwrite @@ -46,6 +48,7 @@ example-test: bench: uv run --group test pytest --benchmark-only +# Bump version, creating a git tag and changelog entry. Level can be "major", "minor", or "patch". bump level: test -z "$(git status --porcelain)" || ( git status && false ) uv version --bump {{level}} @@ -54,8 +57,10 @@ bump level: git commit -m "Bump to v$(uv version --short)" git tag -a "v$(uv version --short)" -m "$(changelog entry latest)" +# Run pre-commit hooks on all files. pre-commit: uv run --group dev prek run --all-files +# Run a REPL with all dependencies installed. repl: uv run --all-groups --all-extras --with ipython ipython diff --git a/src/transformnd/base.py b/src/transformnd/base.py index abb7841..22bb2c0 100644 --- a/src/transformnd/base.py +++ b/src/transformnd/base.py @@ -240,7 +240,11 @@ def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]: class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]): - """Chain transforms, applying one after another.""" + """Chain transforms, applying one after another. + + The `TransformSequence()` constructor takes a sequence of transforms which must not be empty. + Empty sequences can be handled with `TransformSequence.empty(ndim: int)`. + """ def __init__( self, @@ -248,8 +252,10 @@ def __init__( ) -> None: """Combine transforms by chaining them. - Also checks for consistent dimensionality and space references, - inferring if None. + Empty sequences raise an error; + use the `TransformSequence.empty(ndim)` constructor instead. + + Also checks for consistent dimensionality. Parameters ---------- @@ -260,11 +266,13 @@ def __init__( Raises ------ ValueError - If spaces are incompatible. + If spaces are incompatible, or no spaces are given. """ ts = list(transforms) if not ts: - raise ValueError("Empty transform sequence") + raise ValueError( + "Empty transform sequence; use TransformSequence.empty(ndim)" + ) for idx, (t1, t2) in enumerate(pairwise(ts)): if t1.ndims.target != t2.ndims.source: @@ -280,6 +288,14 @@ def __init__( self.transforms: list[Transform[ArrayT]] = ts + @classmethod + def empty(cls, ndim: int) -> Self: + from .transforms import Identity + + out = cls([Identity(ndim)]) + out.transforms.pop() + return out + def __iter__(self) -> Iterator[Transform[ArrayT]]: """Iterate through component transforms. @@ -299,6 +315,9 @@ def __len__(self) -> int: return len(self.transforms) def invert(self) -> Transform[ArrayT] | None: + if self.is_empty(): + return type(self).empty(self.ndims.source) + try: transforms = [~t for t in reversed(self.transforms)] except NotImplementedError: @@ -314,7 +333,8 @@ def apply(self, coords: ArrayT) -> ArrayT: def to_device(self, xp: ModuleType, device: str | None = None) -> Self: result = copy(self) - result.transforms = [t.to_device(xp, device) for t in self.transforms] + if not self.is_empty(): + result.transforms = [t.to_device(xp, device) for t in self.transforms] return result def __str__(self) -> str: @@ -329,10 +349,16 @@ def __getitem__(self, idx: slice | int): def is_identity(self) -> bool: return all(t.is_identity() for t in self) + def is_empty(self) -> bool: + return not self.transforms + def flatten(self, drop_inverse: bool = True) -> Self: """Flatten nested sequences.""" from .transforms.bijection import Bijection + if self.is_empty(): + return copy(self) + out: list[Transform[ArrayT]] = [] for t in self.transforms: @@ -357,7 +383,8 @@ def simplify(self, drop_inverse: bool = True): Does not check whether transforms invert each other, e.g. `Translation(1) | Translation(-1)`. """ - from .transforms import Identity + if self.is_empty(): + return copy(self) out: list[Transform[ArrayT]] = [] affine = None @@ -383,11 +410,13 @@ def simplify(self, drop_inverse: bool = True): add_to_output(affine, out) if not out: - out.append(Identity(self.ndims.source)) + return type(self).empty(self.ndims.source) return type(self)(out) def to_affine(self) -> Affine[ArrayT] | None: + if self.is_empty(): + return Affine.identity(self.ndims.source) # type:ignore simple = self.simplify(True) if len(simple) != 1: return None diff --git a/src/transformnd/graph.py b/src/transformnd/graph.py index 78233e1..61c0c58 100644 --- a/src/transformnd/graph.py +++ b/src/transformnd/graph.py @@ -225,7 +225,21 @@ def get_sequence( ------- TransformSequence[ArrayT] The shortest transform sequence between the spaces. + + Raises + ------ + ValueError + Unknown source or target space. """ + src_ndim = self.ndim(source_space) + if src_ndim is None: + raise ValueError(f"Unknown source space {source_space}") + elif source_space == target_space: + return TransformSequence.empty(src_ndim) # type:ignore + + if self.ndim(target_space) is None: + raise ValueError(f"Unknown target space {source_space}") + path = nx.shortest_path(self.graph, source_space, target_space, weight) # type:ignore transforms = [] if len(path) == 1: diff --git a/src/transformnd/transforms/simple.py b/src/transformnd/transforms/simple.py index 8c4678e..833768b 100644 --- a/src/transformnd/transforms/simple.py +++ b/src/transformnd/transforms/simple.py @@ -45,6 +45,9 @@ def apply(self, coords: ArrayT) -> ArrayT: def __str__(self) -> str: return f"{super().__str__()}({self.ndims.source})" + def is_identity(self) -> bool: + return True + class Translate(Transform[ArrayT]): """Translate coordinates by addition.""" @@ -72,6 +75,10 @@ def __init__( ) super().__init__(NDims(len(self.translation), len(self.translation))) + def is_identity(self) -> bool: + xp = array_namespace(self.translation) + return xp.all(self.translation == 0) + def to_affine(self) -> Affine[ArrayT]: return Affine[ArrayT].translation(self.translation) @@ -140,3 +147,7 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: def __str__(self) -> str: return f"{super().__str__()}({join_strs(self.scale)})" + + def is_identity(self) -> bool: + xp = array_namespace(self.scale) + return xp.all(self.scale == 1) diff --git a/tests/test_base.py b/tests/test_base.py index f2eb170..7f2278f 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -121,3 +121,12 @@ def test_to_affine_no(): seq = TransformSequence([t1, t2, t3]) aff = seq.to_affine() assert aff is None + + +def test_empty_seq(): + t = TransformSequence.empty(2) + assert t.is_identity() + assert t.ndims.source == 2 + + with pytest.raises(ValueError): + t = TransformSequence([]) diff --git a/tests/test_graph.py b/tests/test_graph.py index bf79708..083ce92 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -87,6 +87,13 @@ def test_multigraph(): assert isinstance(seq[0], Translate) +def test_identity_sequence(): + g = TransformGraph() + g.add_transform(Spaced(Translate([1, 2, 3]), "a", "b")) + seq = g.get_sequence("a", "a") + assert seq.is_identity() + + if __name__ == "__main__": test_graph_traversal() print("All tests passed!")