Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,19 @@ 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

# Run benchmarks.
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}}
Expand All @@ -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
45 changes: 37 additions & 8 deletions src/transformnd/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,22 @@ 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,
transforms: Sequence[Transform[ArrayT]],
) -> 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
----------
Expand All @@ -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:
Expand All @@ -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.

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/transformnd/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions src/transformnd/transforms/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
9 changes: 9 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
7 changes: 7 additions & 0 deletions tests/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!")