From faf739355957d782cb73ffce0b17c584fbbe3b85 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Tue, 7 Jul 2026 14:51:58 +0100 Subject: [PATCH 1/6] Remove space references from individual transforms --- examples/image.py | 2 - examples/tutorial.py | 1 - src/transformnd/base.py | 110 ++---------------- src/transformnd/graph.py | 60 +++------- src/transformnd/transforms/affine.py | 24 ++-- src/transformnd/transforms/bijection.py | 17 +-- src/transformnd/transforms/by_dimension.py | 3 +- src/transformnd/transforms/map_axis.py | 11 +- .../transforms/moving_least_squares.py | 2 - src/transformnd/transforms/project_axis.py | 9 +- src/transformnd/transforms/reflection.py | 10 +- src/transformnd/transforms/simple.py | 27 ++--- src/transformnd/transforms/thinplate.py | 3 +- src/transformnd/transforms/vector_field.py | 20 +--- tests/common.py | 8 +- tests/test_base.py | 27 +---- tests/test_graph.py | 31 +++-- tests/transforms/test_simple.py | 15 ++- 18 files changed, 80 insertions(+), 300 deletions(-) diff --git a/examples/image.py b/examples/image.py index b5e6655..6dab81d 100644 --- a/examples/image.py +++ b/examples/image.py @@ -60,7 +60,6 @@ def _(CELLS_SPACE, VIEWPORT_SPACE, WORLD_SPACE): # Scale the space axes Scale([1, 0.29, 0.26, 0.26]), ], - spaces=tnd.Spaces(CELLS_SPACE, WORLD_SPACE), ) print(cells_to_world) @@ -75,7 +74,6 @@ def _(CELLS_SPACE, VIEWPORT_SPACE, WORLD_SPACE): # Choose a spatial sampling frequency (here 0.2um isotropic) Scale([1, 0.2, 0.2, 0.2]), ], - spaces=tnd.Spaces(VIEWPORT_SPACE, WORLD_SPACE), ) print(viewport_to_world) return cells_to_world, viewport_to_world diff --git a/examples/tutorial.py b/examples/tutorial.py index e5ce025..2b339cf 100644 --- a/examples/tutorial.py +++ b/examples/tutorial.py @@ -267,7 +267,6 @@ def apply(self, coords: np.ndarray) -> np.ndarray: def invert(self): return type(self)( 1 / self.factor, - spaces=self.spaces.invert(), ) return diff --git a/src/transformnd/base.py b/src/transformnd/base.py index 666770b..82a6610 100644 --- a/src/transformnd/base.py +++ b/src/transformnd/base.py @@ -11,14 +11,11 @@ from array_api_compat import array_namespace from .util import ( - SpaceRef, - same_or_none, - space_str, ArrayT, ) from itertools import pairwise -from .types import TransformSignature, Spaces, NDims +from .types import TransformSignature, NDims if TYPE_CHECKING: from .transforms import Affine @@ -30,19 +27,14 @@ class Transform[ArrayT](ABC): def __init__( self, ndims: NDims, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters ---------- ndims Source and target dimensionality. - spaces - Optional source and target spaces """ self.ndims: NDims = ndims - self.spaces: Spaces = spaces def is_identity(self) -> bool: """Whether this is a no-op transformation.""" @@ -166,7 +158,6 @@ def __or__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]: transforms = as_transform_list(self) + as_transform_list(other) return TransformSequence[ArrayT]( transforms, - spaces=Spaces(self.spaces.source, other.spaces.target), ) def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]: @@ -189,14 +180,11 @@ def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]: transforms = as_transform_list(other) + as_transform_list(self) return TransformSequence( transforms, - spaces=Spaces(other.spaces.source, self.spaces.target), ) - def __str__(self) -> str: - cls_name = type(self).__name__ - src = space_str(self.spaces.source) - tgt = space_str(self.spaces.target) - return f"{cls_name}[{src}->{tgt}]" + # def __str__(self) -> str: + # cls_name = type(self).__name__ + # return f"{cls_name}" class TransformWrapper(Transform[ArrayT]): @@ -207,8 +195,6 @@ def __init__( fn: TransformSignature[ArrayT], in_ndim: int, out_ndim: int, - *, - spaces: Spaces = Spaces(None, None), ): """Wrapper around an arbitrary function. @@ -223,10 +209,8 @@ def __init__( Dimensionality of the input coordinates. out_ndim Dimensionality of the output coordinates. - spaces - Optional source and target spaces """ - super().__init__(NDims(in_ndim, out_ndim), spaces=spaces) + super().__init__(NDims(in_ndim, out_ndim)) self.fn = fn def apply(self, coords: ArrayT) -> ArrayT: @@ -234,37 +218,6 @@ def apply(self, coords: ArrayT) -> ArrayT: return self.fn(coords) -def _with_spaces( - t: Transform[ArrayT], - source_space: SpaceRef | None = None, - target_space: SpaceRef | None = None, -) -> Transform[ArrayT]: - src_tgt = (t.spaces.source, t.spaces.target) - src = same_or_none(src_tgt[0], source_space, default=None) - tgt = same_or_none(src_tgt[1], target_space, default=None) - if (src, tgt) != src_tgt: - t = copy(t) - t.spaces = Spaces(src, tgt) - return t - - -def infer_spaces( - transforms: Sequence[Transform[ArrayT]], source_space=None, target_space=None -) -> list[Transform[ArrayT]]: - prev_tgts = [source_space] - next_srcs = [] - for t1, t2 in pairwise(transforms): - prev_tgts.append(t1.spaces.target) - next_srcs.append(t2.spaces.source) - - next_srcs.append(target_space) - - out = [] - for t, next_src, prev_tgt in zip(transforms, next_srcs, prev_tgts): - out.append(_with_spaces(t, prev_tgt, next_src)) - return out - - def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]: if isinstance(t, TransformSequence): return t.transforms.copy() @@ -278,8 +231,6 @@ class TransformSequence(Transform[ArrayT], Sequence[Transform[ArrayT]]): def __init__( self, transforms: Sequence[Transform[ArrayT]], - *, - spaces: Spaces = Spaces(None, None), ) -> None: """Combine transforms by chaining them. @@ -291,16 +242,13 @@ def __init__( transforms : Items which are a TransformSequences will each still be treated as a single transform. - spaces : - Optional source and target spaces. - Can also be inferred from the first and last transforms. Raises ------ ValueError If spaces are incompatible. """ - ts = infer_spaces(transforms, *spaces) + ts = list(transforms) if not ts: raise ValueError("Empty transform sequence") @@ -312,13 +260,9 @@ def __init__( f"and the next source is {t2.ndims.source}D" ) - spaces = Spaces(ts[0].spaces.source, ts[-1].spaces.target) ndims = NDims(ts[0].ndims.source, ts[-1].ndims.target) - super().__init__( - ndims, - spaces=spaces, - ) + super().__init__(ndims) self.transforms: list[Transform[ArrayT]] = ts @@ -347,7 +291,6 @@ def invert(self) -> Transform[ArrayT] | None: return None return type(self)( transforms, - spaces=self.spaces.invert(), ) def apply(self, coords: ArrayT) -> ArrayT: @@ -360,41 +303,9 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: result.transforms = [t.to_device(xp, device) for t in self.transforms] return result - def list_spaces(self, skip_none: bool = False) -> list[SpaceRef]: - """List spaces in this transform. - - Parameters - ---------- - skip_none - Whether to skip undefined spaces, default False. - - Returns - ------- - list[SpaceRef] - The list of spaces. - """ - spaces = [self.spaces.source] + [t.spaces.target for t in self.transforms] - if skip_none: - spaces = [s for s in spaces if s is not None] - return spaces - - def split(self) -> Iterator[Transform[ArrayT]]: - """Split the sequence where an intermediate space is known.""" - this_seq = [] - - for t in self.transforms: - if t.spaces.source is not None and t.spaces.target is not None: - yield t - continue - - this_seq.append(t) - if t.spaces.target is not None: - yield type(self)(this_seq) - this_seq = [] - def __str__(self) -> str: cls_name = type(self).__name__ - spaces_str = "->".join(space_str(s) for s in self.list_spaces()) + spaces_str = "|".join(str(t) for t in self.transforms) return f"{cls_name}[{spaces_str}]" def __getitem__(self, idx: slice | int): @@ -418,7 +329,7 @@ def flatten(self, drop_inverse: bool = True) -> Self: out.extend(t.flatten()) else: out.append(t) - return TransformSequence(out, spaces=self.spaces) # type:ignore + return TransformSequence(out) # type:ignore def simplify(self, drop_inverse: bool = True): """Reduce the number of transformations in this sequence if possible. @@ -461,7 +372,7 @@ def simplify(self, drop_inverse: bool = True): if not out: out.append(Identity(self.ndims.source)) - return type(self)(out, spaces=self.spaces) + return type(self)(out) def to_affine(self) -> Affine[ArrayT] | None: simple = self.simplify(True) @@ -475,6 +386,5 @@ def add_to_output(transform: Transform, lst: list[Transform]) -> bool: return False transform = copy(transform) - transform.spaces = Spaces(None, None) lst.append(transform) return True diff --git a/src/transformnd/graph.py b/src/transformnd/graph.py index 6c6bff9..4b32914 100644 --- a/src/transformnd/graph.py +++ b/src/transformnd/graph.py @@ -23,35 +23,6 @@ WeightFn = Callable[[SpaceRef, SpaceRef, dict[str, Any]], int] -def split_sequence(seq: TransformSequence[ArrayT]) -> Iterator[Transform[ArrayT]]: - """Split a TransformSequence into Transforms with spaces defined. - - If a component Transform has its spaces defined, - it will be yielded as-is. - A chain of Transforms without spaces defined are yielded as a TransformSequence. - - Parameters - ---------- - seq - The TransformSequence to split. - - Yields - ------ - Transform[ArrayT] - Individual transforms or subsequences with defined spaces. - """ - this_seq = [] - for t in seq.transforms: - if t.spaces.source is not None and t.spaces.target is not None: - yield t - continue - - this_seq.append(t) - if t.spaces.target is not None: - yield TransformSequence(this_seq) - this_seq = [] - - def normalise_edge_weight_fn(w: str | WeightFn | None) -> WeightFn: if w is None: return lambda _s, _t, _d: 1 @@ -84,32 +55,28 @@ def __init__( def _update_spaces( self, transform: Transform[ArrayT], - source: SpaceRef | None, - target: SpaceRef | None, + source: SpaceRef, + target: SpaceRef, ) -> Spaces: """Check that the transform's spaces do not conflict with those given explicitly, that the source and target space is defined somewhere, and that the dimensionality of the spaces (inferred from the transforms) does not conflict with known spaces. """ - # check explicit spaces do not conflict with transform's spaces - src = same_or_none(transform.spaces.source, source) - tgt = same_or_none(transform.spaces.target, target) - # if the node already exists, make sure the dimensionality does not conflict - self.space_ndims[src] = same_or_none( - self.space_ndims.get(src), transform.ndims.source + self.space_ndims[source] = same_or_none( + self.space_ndims.get(source), transform.ndims.source ) - self.space_ndims[tgt] = same_or_none( - self.space_ndims.get(tgt), transform.ndims.target + self.space_ndims[target] = same_or_none( + self.space_ndims.get(target), transform.ndims.target ) - return Spaces(src, tgt) + return Spaces(source, target) def _add_transform( self, transform: Transform[ArrayT], - source: SpaceRef | None, - target: SpaceRef | None, + source: SpaceRef, + target: SpaceRef, edge_data: dict[str, Any] | None, ) -> list[tuple[SpaceRef, SpaceRef]]: """Clearing the get_sequence cache and splitting sequences and bijections should be handled outside this method.""" @@ -131,8 +98,8 @@ def _add_transform( def add_transform( self, transform: Transform[ArrayT], - source: SpaceRef | None = None, - target: SpaceRef | None = None, + source: SpaceRef, + target: SpaceRef, *, edge_data: dict[str, Any] | None = None, ) -> list[tuple[SpaceRef, SpaceRef]]: @@ -154,9 +121,9 @@ def add_transform( transform Transform to add to the graph as an edge. source - May be omitted if `transform` has its source space defined. + Identifier for the source space. target - May be omitted if `transform` has its target space defined. + Identifier for the target space. edge_data Dict of string keys to arbitrary values to associate with an edge. Used during path-finding. @@ -232,7 +199,6 @@ def get_sequence( seq = TransformSequence( transforms, - spaces=Spaces(source_space, target_space), ) if not full: seq = seq.simplify(drop_inverse=True) diff --git a/src/transformnd/transforms/affine.py b/src/transformnd/transforms/affine.py index 1933599..bbc660a 100644 --- a/src/transformnd/transforms/affine.py +++ b/src/transformnd/transforms/affine.py @@ -15,7 +15,7 @@ from array_api_compat import array_namespace, device as xp_device from ..base import Transform, ArrayT -from ..util import as_floats, none_eq, is_square +from ..util import as_floats, is_square from ..types import NDims, Spaces @@ -34,8 +34,6 @@ class Affine(Transform[ArrayT]): def __init__( self, matrix: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters @@ -44,8 +42,6 @@ def __init__( Affine transformation matrix, i.e. a 2D array-like with shape `(Do + 1, Di + 1)`, where the bottom row is all 0s except in the rightmost column, which is 1. - spaces - Optional source and target spaces Raises ------ @@ -64,7 +60,7 @@ def __init__( f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})." ) - super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces) + super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1)) self.matrix: np.ndarray = m @@ -117,10 +113,7 @@ def invert(self) -> Self | None: except np.linalg.LinAlgError: return None - return type(self)( - inv, - spaces=self.spaces.invert(), - ) + return type(self)(inv) def __matmul__(self, rhs: Affine[ArrayT]) -> Affine[ArrayT]: """Compose two affine transforms by matrix multiplication. @@ -151,11 +144,8 @@ def __matmul__(self, rhs: Affine[ArrayT]) -> Affine[ArrayT]: # this ordering looks wrong but this is the way affine transforms get combined; # the sequence transform A followed by transform B is expressed B @ A - if not none_eq(self.spaces.source, rhs.spaces.target): - raise ValueError("Affine transforms do not share a space") return Affine( self.matrix @ rhs.matrix, - spaces=Spaces(rhs.spaces.source, self.spaces.target), ) def to_device(self, xp: ModuleType, device: str | None = None) -> "Affine[ArrayT]": @@ -225,7 +215,7 @@ def from_linear_map( "Translation array must be the same length as linear map columns" ) matrix[:-1, -1] = translation - return cls(matrix, spaces=spaces) + return cls(matrix) @classmethod def identity( @@ -248,7 +238,7 @@ def identity( Affine[ArrayT] The identity affine transform. """ - return cls(np.eye(ndim + 1), spaces=spaces) + return cls(np.eye(ndim + 1)) @classmethod def translation( @@ -281,7 +271,7 @@ def translation( raise ValueError(f"Translation array must be 1D; got shape {t.shape}") m = np.eye(len(t) + 1, dtype=t.dtype) m[:-1, -1] = t - return cls(m, spaces=spaces) + return cls(m) @classmethod def scaling( @@ -496,7 +486,7 @@ def shearing( def __eq__(self, other: object) -> bool: if not isinstance(other, Affine): return NotImplemented - return np.array_equal(self.matrix, other.matrix) and self.spaces == other.spaces + return np.array_equal(self.matrix, other.matrix) def is_identity(self) -> bool: xp = array_namespace(self.matrix) diff --git a/src/transformnd/transforms/bijection.py b/src/transformnd/transforms/bijection.py index 8fc7d5c..792dbff 100644 --- a/src/transformnd/transforms/bijection.py +++ b/src/transformnd/transforms/bijection.py @@ -5,8 +5,6 @@ from transformnd.transforms.affine import Affine from ..base import Transform, ArrayT -from ..types import Spaces -from ..util import same_or_none class Bijection(Transform[ArrayT]): @@ -18,8 +16,6 @@ def __init__( self, forward: Transform[ArrayT], inverse: Transform[ArrayT], - *, - spaces: Spaces = Spaces(None, None), ): """Base class for transformations. @@ -29,34 +25,25 @@ def __init__( The forward transformation. inverse The inverse transformation. - spaces - Optional source and target spaces Raises ------ ValueError If the forward and inverse dimensionalities don't match. """ - src = same_or_none( - spaces.source, forward.spaces.source, inverse.spaces.target, default=None - ) - tgt = same_or_none( - spaces.target, forward.spaces.target, inverse.spaces.source, default=None - ) - self.forward = forward self.inverse = inverse if forward.ndims != inverse.ndims.invert(): raise ValueError( f"Bijection dimensionalities mismatch: fwd:{forward.ndims}, inv:{inverse.ndims}" ) - super().__init__(self.forward.ndims, spaces=Spaces(src, tgt)) + super().__init__(self.forward.ndims) def apply(self, coords: ArrayT) -> ArrayT: return self.forward.apply(coords) def invert(self) -> Self | None: - return type(self)(self.inverse, self.forward, spaces=self.spaces.invert()) + return type(self)(self.inverse, self.forward) def is_identity(self) -> bool: return self.forward.is_identity() and self.inverse.is_identity() diff --git a/src/transformnd/transforms/by_dimension.py b/src/transformnd/transforms/by_dimension.py index 10240c9..a47049f 100644 --- a/src/transformnd/transforms/by_dimension.py +++ b/src/transformnd/transforms/by_dimension.py @@ -118,7 +118,7 @@ def __init__( if sorted_out != list(range(len(sorted_out))): raise ValueError("N-length output axes must go from 0 to N-1") - super().__init__(NDims(len(sorted_in), len(sorted_out)), spaces=spaces) + super().__init__(NDims(len(sorted_in), len(sorted_out))) self.subtransforms = subtransforms def apply(self, coords: ArrayT) -> ArrayT: @@ -147,7 +147,6 @@ def invert(self) -> Transform[ArrayT] | None: return type(self)( subtransforms=inverted_transforms, - spaces=self.spaces.invert(), ) def is_identity(self) -> bool: diff --git a/src/transformnd/transforms/map_axis.py b/src/transformnd/transforms/map_axis.py index fc68f99..b680165 100644 --- a/src/transformnd/transforms/map_axis.py +++ b/src/transformnd/transforms/map_axis.py @@ -4,7 +4,7 @@ from ..base import Transform from ..util import ArrayT -from ..types import Spaces, NDims +from ..types import NDims from ..transforms.affine import Affine @@ -16,8 +16,6 @@ class MapAxis(Transform[ArrayT]): def __init__( self, permutation: list[int], - *, - spaces: Spaces = Spaces(None, None), ): """Base class for transformations. @@ -25,8 +23,6 @@ def __init__( ---------- permutation New order of column axis. For example, [1, 0] means x -> y and y -> x. - spaces - Optional source and target spaces Raises ------ @@ -39,7 +35,7 @@ def __init__( "N-D permutation must contain all dimensions [0, N) exactly once" ) self.permutation = permutation - super().__init__(NDims(len(permutation), len(permutation)), spaces=spaces) + super().__init__(NDims(len(permutation), len(permutation))) def is_identity(self) -> bool: return all(a == b for a, b in enumerate(self.permutation)) @@ -47,7 +43,7 @@ def is_identity(self) -> bool: def to_affine(self) -> Affine[ArrayT] | None: m = np.eye(self.ndims.source) m = m[self.permutation, :] - return Affine.from_linear_map(m, spaces=self.spaces) # type: ignore + return Affine.from_linear_map(m) # type: ignore def apply(self, coords: ArrayT) -> ArrayT: """Apply transformation to coordinates. @@ -64,5 +60,4 @@ def apply(self, coords: ArrayT) -> ArrayT: def invert(self) -> Self | None: return type(self)( list(np.argsort(self.permutation)), - spaces=self.spaces.invert(), ) diff --git a/src/transformnd/transforms/moving_least_squares.py b/src/transformnd/transforms/moving_least_squares.py index 456c766..e7e0ff8 100644 --- a/src/transformnd/transforms/moving_least_squares.py +++ b/src/transformnd/transforms/moving_least_squares.py @@ -50,7 +50,6 @@ def __init__( s.shape[1], t.shape[1], ), - spaces=spaces, ) def apply(self, coords: np.ndarray) -> np.ndarray: @@ -70,5 +69,4 @@ def invert(self) -> Self | None: return type(self)( self._transformer.deformed_control_points, self._transformer.control_points, - spaces=self.spaces.invert(), ) diff --git a/src/transformnd/transforms/project_axis.py b/src/transformnd/transforms/project_axis.py index eed8ae4..69112a9 100644 --- a/src/transformnd/transforms/project_axis.py +++ b/src/transformnd/transforms/project_axis.py @@ -4,7 +4,7 @@ import numpy as np from array_api_compat import array_namespace from transformnd.transforms import Affine -from transformnd.types import NDims, Spaces +from transformnd.types import NDims from ..base import Transform from ..types import ArrayT @@ -21,8 +21,6 @@ def __init__( created: set[int] | None = None, source_ndim: int | None = None, target_ndim: int | None = None, - *, - spaces: Spaces = Spaces(None, None), ): """Create a transform for adding and dropping axes. @@ -38,8 +36,6 @@ def __init__( If omitted, can be inferred from `target_ndim`. target_ndim If omitted, can be inferred from `source_ndim`. - spaces - Identifiers for source and target spaces, by default Spaces(None, None) Raises ------ @@ -74,7 +70,7 @@ def __init__( idxs.insert(create, None) self._idxs = idxs - super().__init__(NDims(source_ndim, target_ndim), spaces=spaces) + super().__init__(NDims(source_ndim, target_ndim)) def apply(self, coords: ArrayT) -> ArrayT: coords = self._validate_coords(coords) @@ -103,5 +99,4 @@ def invert(self) -> Self | None: self.dropped, source_ndim=self.ndims.target, target_ndim=self.ndims.source, - spaces=self.spaces.invert(), ) diff --git a/src/transformnd/transforms/reflection.py b/src/transformnd/transforms/reflection.py index a520e42..3306cd5 100644 --- a/src/transformnd/transforms/reflection.py +++ b/src/transformnd/transforms/reflection.py @@ -90,8 +90,6 @@ def __init__( self, normals: ArrayLike, point: float | ArrayLike = 0.0, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters @@ -102,8 +100,6 @@ def __init__( point Intersection point of all reflection planes (can be broadcast from scalar), by default 0 (i.e. the origin) - spaces - Optional source and target spaces Raises ------ @@ -125,7 +121,7 @@ def __init__( self.ndim = {len(n1)} self.normals = [unitise(n) for n in normals] # todo: matmul is associative, so turn this into an affine in 2/3D? - super().__init__(NDims(len(n1), len(n1)), spaces=spaces) + super().__init__(NDims(len(n1), len(n1))) def apply(self, coords: np.ndarray) -> np.ndarray: coords = self._validate_coords(coords) @@ -158,7 +154,7 @@ def from_points( Self """ point, normals = get_hyperplanes(np.asarray(points), unitise=False) - return cls(normals, point, spaces=spaces) + return cls(normals, point) @classmethod def from_axis( @@ -204,7 +200,7 @@ def from_axis( v[i] += 1 normals.append(v) - return cls(normals, origin, spaces=spaces) + return cls(normals, origin) def invert(self) -> Self | None: return copy(self) diff --git a/src/transformnd/transforms/simple.py b/src/transformnd/transforms/simple.py index 170d9c4..f5c50df 100644 --- a/src/transformnd/transforms/simple.py +++ b/src/transformnd/transforms/simple.py @@ -12,7 +12,7 @@ from array_api_compat import device as xp_device from ..base import Transform from ..types import NDims, Spaces -from ..util import ArrayT, chain_or, as_floats +from ..util import ArrayT, as_floats from ..transforms.affine import Affine @@ -22,8 +22,6 @@ class Identity(Transform[ArrayT]): def __init__( self, ndim: int, - *, - spaces: Spaces = Spaces(None, None), ): """ Transform which does nothing. @@ -32,18 +30,14 @@ def __init__( ---------- ndim: Number of dimensions of this transform. - spaces: - Optional source and target spaces """ - src = chain_or(*spaces, default=None) - tgt = chain_or(*spaces[::-1], default=None) - super().__init__(NDims(ndim, ndim), spaces=Spaces(src, tgt)) + super().__init__(NDims(ndim, ndim)) def invert(self) -> Transform[ArrayT]: - return type(self)(self.ndims.source, spaces=self.spaces.invert()) + return type(self)(self.ndims.source) def to_affine(self) -> Affine[ArrayT] | None: - return Affine[ArrayT].identity(self.ndims.source, spaces=self.spaces) + return Affine[ArrayT].identity(self.ndims.source) def apply(self, coords: ArrayT) -> ArrayT: return coords @@ -77,12 +71,10 @@ def __init__( raise ValueError( f"Translation must be 1D, got shape {self.translation.shape}" ) - super().__init__( - NDims(len(self.translation), len(self.translation)), spaces=spaces - ) + super().__init__(NDims(len(self.translation), len(self.translation))) def to_affine(self) -> Affine[ArrayT] | None: - return Affine[ArrayT].translation(self.translation, spaces=self.spaces) + return Affine[ArrayT].translation(self.translation) def apply(self, coords: ArrayT) -> ArrayT: coords = self._validate_coords(coords) @@ -91,7 +83,7 @@ def apply(self, coords: ArrayT) -> ArrayT: return coords + xp.asarray(self.translation, device=d) def invert(self) -> Transform | None: - return type(self)(-self.translation, spaces=self.spaces.invert()) + return type(self)(-self.translation) def to_device(self, xp: ModuleType, device: str | None = None) -> Self: result = copy(self) @@ -127,10 +119,10 @@ def __init__( self.scale = as_floats(scale) if self.scale.ndim != 1: raise ValueError(f"Scale must be 1D, got shape {self.scale.shape}") - super().__init__(NDims(len(self.scale), len(self.scale)), spaces=spaces) + super().__init__(NDims(len(self.scale), len(self.scale))) def to_affine(self) -> Affine[ArrayT] | None: - return Affine[ArrayT].scaling(self.scale, spaces=self.spaces) + return Affine[ArrayT].scaling(self.scale) def apply(self, coords: ArrayT) -> ArrayT: coords = self._validate_coords(coords) @@ -141,7 +133,6 @@ def apply(self, coords: ArrayT) -> ArrayT: def invert(self) -> Self | None: return type(self)( 1 / self.scale, - spaces=self.spaces.invert(), ) def to_device(self, xp: ModuleType, device: str | None = None) -> Self: diff --git a/src/transformnd/transforms/thinplate.py b/src/transformnd/transforms/thinplate.py index 9a558a8..3e58e3d 100644 --- a/src/transformnd/transforms/thinplate.py +++ b/src/transformnd/transforms/thinplate.py @@ -66,13 +66,12 @@ def __init__( self.source_control_points, self.target_control_points, ) - super().__init__(NDims(ndim, ndim), spaces=spaces) + super().__init__(NDims(ndim, ndim)) def invert(self) -> Transform[np.ndarray] | None: return type(self)( self.target_control_points, self.source_control_points, - spaces=self.spaces.invert(), ) def apply(self, coords: np.ndarray) -> np.ndarray: diff --git a/src/transformnd/transforms/vector_field.py b/src/transformnd/transforms/vector_field.py index c880f8a..064ba55 100644 --- a/src/transformnd/transforms/vector_field.py +++ b/src/transformnd/transforms/vector_field.py @@ -6,7 +6,7 @@ import numpy as np from array_api_compat import array_namespace, is_dask_array -from ..types import NDims, Spaces +from ..types import NDims from ..base import Transform, ArrayT from ..util import set_scipy_array_api, as_floats @@ -20,8 +20,6 @@ def __init__( index_transform: Transform[ArrayT] | None = None, interpolation_order: int = 3, vector_axis: int = -1, - *, - spaces: Spaces = Spaces(None, None), ): """Look up a vector in array. @@ -35,8 +33,6 @@ def __init__( Order of the spline interpolation used for coordinates which are not integer array indices. vector_axis Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). - spaces - References for source and target spaces Raises ------ @@ -62,7 +58,7 @@ def __init__( self._mode = "constant" self._cval = np.nan self._order = interpolation_order - super().__init__(NDims(source_ndim, tgt_ndim), spaces=spaces) + super().__init__(NDims(source_ndim, tgt_ndim)) def _vf_slices(self) -> Iterable[ArrayT]: slicing: list[slice | int] = [slice(None)] * (self.ndims.source + 1) @@ -123,7 +119,7 @@ def _get_vectors(self, coords: ArrayT) -> ArrayT: def to_device(self, xp: ModuleType, device: str | None = None) -> Self: coords = xp.asarray(self.vector_field, device) - return type(self)(coords, spaces=self.spaces) + return type(self)(coords) class Coordinates(BaseVectorField[ArrayT]): @@ -144,8 +140,6 @@ def __init__( index_transform: Transform[ArrayT] | None = None, interpolation_order: int = 3, vector_axis: int = -1, - *, - spaces: Spaces = Spaces(None, None), ): """Use the input coordinates as array indices to look up output coordinates. @@ -163,15 +157,12 @@ def __init__( Order of the spline interpolation used for coordinates which are not integer array indices. vector_axis Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). - spaces - References for source and target spaces """ super().__init__( vector_field, index_transform, interpolation_order, vector_axis, - spaces=spaces, ) def apply(self, coords: ArrayT) -> ArrayT: @@ -196,8 +187,6 @@ def __init__( index_transform: Transform[ArrayT] | None = None, interpolation_order: int = 3, vector_axis: int = -1, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters @@ -210,8 +199,6 @@ def __init__( Order of the spline interpolation used for coordinates which are not integer array indices. vector_axis Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). - spaces - References for source and target spaces Raises ------ @@ -224,7 +211,6 @@ def __init__( index_transform, interpolation_order, vector_axis, - spaces=spaces, ) if self.ndims.source != self.ndims.target: raise ValueError("Displacements cannot change dimensionality") diff --git a/tests/common.py b/tests/common.py index e33f235..9939178 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,5 +1,5 @@ from transformnd.base import Transform -from transformnd.types import Spaces, NDims +from transformnd.types import NDims from transformnd.transforms.affine import Affine from copy import copy import numpy as np @@ -13,10 +13,8 @@ def __init__( ndim: int, invertible: bool = False, affineable: bool = False, - *, - spaces: Spaces = Spaces(None, None), ): - super().__init__(NDims(ndim, ndim), spaces=spaces) + super().__init__(NDims(ndim, ndim)) self.invertible = invertible self.affineable = affineable @@ -27,7 +25,7 @@ def invert(self) -> Transform | None: def to_affine(self) -> Affine | None: if self.affineable: - return Affine.identity(self.ndims.source, spaces=self.spaces) + return Affine.identity(self.ndims.source) return None def apply(self, coords: np.ndarray) -> np.ndarray: diff --git a/tests/test_base.py b/tests/test_base.py index 3f75a8a..a8392d2 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -4,7 +4,6 @@ import pytest from transformnd.base import TransformSequence, TransformWrapper -from transformnd.types import Spaces from transformnd.transforms.simple import Translate, Scale from transformnd.transforms.affine import Affine from itertools import pairwise @@ -17,7 +16,7 @@ def noop(arg): def test_transform(coords5x3): - t = TransformWrapper(noop, 3, 3, spaces=Spaces(1, 2)) + t = TransformWrapper(noop, 3, 3) assert np.allclose(t.apply(coords5x3), coords5x3) @@ -26,22 +25,10 @@ def test_sequence(coords5x3): ts = [] last = 3 for a, b in pairwise(range(last + 1)): - ts.append(TransformWrapper(noop, 3, 3, spaces=Spaces(a, b))) + ts.append(TransformWrapper(noop, 3, 3)) t = TransformSequence(ts) assert np.allclose(t.apply(coords5x3), coords5x3) - assert t.spaces.source == 0 - assert t.spaces.target == last - - -def test_sequence_errors(): - with pytest.raises(ValueError): - TransformSequence( - [ - TransformWrapper(noop, 3, 3, spaces=Spaces(1, 2)), - TransformWrapper(noop, 3, 3, spaces=Spaces(3, 4)), - ] - ) def test_sequence_does_not_split(): @@ -52,16 +39,6 @@ def test_sequence_does_not_split(): assert seq2[1] is seq1 -def test_sequence_infers(): - t = TransformSequence( - [ - TransformWrapper(noop, 3, 3, spaces=Spaces(0, None)), - TransformWrapper(noop, 3, 3, spaces=Spaces(1, 2)), - ] - ) - assert t[0].spaces.target == 1 - - def test_add(): t = [TransformWrapper(noop, 3, 3) for _ in range(5)] t12 = t[1] | t[2] diff --git a/tests/test_graph.py b/tests/test_graph.py index 0fe890d..e262e80 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -2,7 +2,6 @@ from transformnd.base import Transform, TransformSequence from transformnd.graph import TransformGraph -from transformnd.types import Spaces from transformnd.transforms.simple import Translate, Scale from transformnd.transforms.affine import Affine @@ -15,50 +14,48 @@ def test_add_transforms(): t: TransformGraph = TransformGraph() - t.add_transform(Scale([2, 2], spaces=Spaces("a", "b"))) - t.add_transform(Translate([10, 20], spaces=Spaces("b", "c"))) + t.add_transform(Scale([2, 2]), 1, 2) + t.add_transform(Translate([10, 20]), 2, 3) @pytest.mark.parametrize(("full",), [(True,), (False,)]) def test_noop_path(coords5x3, full): t: TransformGraph = TransformGraph() - t.add_transform(Scale([2, 2], spaces=Spaces("a", "b"))) - s = t.get_sequence("a", "a", full=full) + t.add_transform(Scale([2, 2]), 1, 2) + s = t.get_sequence(1, 1, full=full) assert s.apply(coords5x3) == pytest.approx(coords5x3) def test_path(): - t1 = Scale([2, 3], spaces=Spaces("a", "b")) - t2 = Translate([10, 20], spaces=Spaces("b", "c")) + t1 = Scale([2, 3]) + t2 = Translate([10, 20]) transforms = [t1, t2] g: TransformGraph = TransformGraph() - g.add_transform(t1) - g.add_transform(t2) - spaces = ("a", "c") + g.add_transform(t1, 1, 2) + g.add_transform(t2, 2, 3) + spaces = (1, 3) seq = g.get_sequence(*spaces, full=True) assert len(seq) == 2 assert isinstance(seq[0], Scale) assert isinstance(seq[1], Translate) in_coords = as_floats([[0, 0], [1, 1], [2, 2]]) - res = g.transform("a", "c", in_coords) + res = g.transform(1, 3, in_coords) expected = TransformSequence(transforms).apply(in_coords) assert res == pytest.approx(expected) def test_graph_traversal(): graph: TransformGraph = TransformGraph() - graph.add_transform(Translate([1, 2], spaces=Spaces("A", "B"))) - graph.add_transform(Scale([2, 3], spaces=Spaces("B", "C"))) - graph.add_transform(Affine(np.eye(3), spaces=Spaces("C", "D"))) + graph.add_transform(Translate([1, 2]), 1, 2) + graph.add_transform(Scale([2, 3]), 2, 3) + graph.add_transform(Affine(np.eye(3)), 3, 4) - seq = graph.get_sequence("A", "D") + seq = graph.get_sequence(1, 4) assert isinstance(seq, TransformSequence) assert len(seq) == 1 simplified_seq = seq.simplify() - assert simplified_seq.spaces.source == "A" - assert simplified_seq.spaces.target == "D" expected_affine = np.array([[2, 0, 2], [0, 3, 6], [0, 0, 1]]) got_affine = simplified_seq.to_affine() diff --git a/tests/transforms/test_simple.py b/tests/transforms/test_simple.py index e6771f6..f926d58 100644 --- a/tests/transforms/test_simple.py +++ b/tests/transforms/test_simple.py @@ -1,35 +1,34 @@ import numpy as np from transformnd.transforms.simple import Identity, Scale, Translate -from transformnd.types import Spaces import pytest -def test_identity_spaces(): - t = Identity(1, spaces=Spaces(1, 1)) - assert t.spaces.target == 1 +def test_identity(coords5x3): + t = Identity(coords5x3.shape[1]) + assert t.apply(coords5x3) == pytest.approx(coords5x3) def test_translate_3d(coords5x3): trans = [1, 2, 3] t = Translate(np.array(trans)) - assert np.allclose(t.apply(coords5x3), coords5x3 + trans) + assert t.apply(coords5x3) == pytest.approx(coords5x3 + trans) def test_translate_neg(coords5x3): t_neg = ~Translate([1] * 3) - assert np.allclose(t_neg.apply(coords5x3), coords5x3 - 1) + assert t_neg.apply(coords5x3) == pytest.approx(coords5x3 - 1) def test_scale_3d(coords5x3): scale = [2, 3, 4] t = Scale(np.array(scale)) - assert np.allclose(t.apply(coords5x3), coords5x3 * scale) + assert t.apply(coords5x3) == pytest.approx(coords5x3 * scale) def test_scale_neg(coords5x3): t_neg = ~Scale([2] * 3) - assert np.allclose(t_neg.apply(coords5x3), coords5x3 / 2) + assert t_neg.apply(coords5x3) == pytest.approx(coords5x3 / 2) def test_translation_jax(coords5x3): From dabfb2c2ed38a8c1366fb10ec300421b34f1dc6e Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Tue, 7 Jul 2026 14:57:55 +0100 Subject: [PATCH 2/6] remove more references to spaces --- src/transformnd/__init__.py | 3 +- src/transformnd/graph.py | 4 +- src/transformnd/transforms/affine.py | 44 +++---------------- src/transformnd/transforms/by_dimension.py | 6 +-- src/transformnd/transforms/grid.py | 43 ++++++++++++++++++ .../transforms/moving_least_squares.py | 6 +-- src/transformnd/transforms/reflection.py | 10 +---- src/transformnd/transforms/simple.py | 10 +---- src/transformnd/transforms/thinplate.py | 6 +-- src/transformnd/util.py | 10 +---- 10 files changed, 58 insertions(+), 84 deletions(-) create mode 100644 src/transformnd/transforms/grid.py diff --git a/src/transformnd/__init__.py b/src/transformnd/__init__.py index ae5b2e1..8bee1cc 100644 --- a/src/transformnd/__init__.py +++ b/src/transformnd/__init__.py @@ -9,8 +9,7 @@ """ from .base import Transform, TransformSequence, TransformWrapper -from .util import SpaceRef -from .types import Spaces, TransformSignature, NDims +from .types import Spaces, TransformSignature, NDims, SpaceRef from . import transforms from . import adapters from .graph import TransformGraph diff --git a/src/transformnd/graph.py b/src/transformnd/graph.py index 4b32914..a4bfb68 100644 --- a/src/transformnd/graph.py +++ b/src/transformnd/graph.py @@ -14,8 +14,8 @@ from .transforms.bijection import Bijection from .base import Transform, TransformSequence -from .util import SpaceRef, ArrayT, same_or_none -from .types import Spaces +from .util import ArrayT, same_or_none +from .types import Spaces, SpaceRef logger = logging.getLogger(__name__) diff --git a/src/transformnd/transforms/affine.py b/src/transformnd/transforms/affine.py index bbc660a..941763b 100644 --- a/src/transformnd/transforms/affine.py +++ b/src/transformnd/transforms/affine.py @@ -16,7 +16,7 @@ from array_api_compat import array_namespace, device as xp_device from ..base import Transform, ArrayT from ..util import as_floats, is_square -from ..types import NDims, Spaces +from ..types import NDims class Affine(Transform[ArrayT]): @@ -175,8 +175,6 @@ def from_linear_map( cls, linear_map: ArrayLike, translation: ArrayLike | None = None, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an augmented affine matrix from a linear map, with an optional translation. @@ -187,8 +185,6 @@ def from_linear_map( Shape `(Di, Do)` translation Translation to add to the matrix, by default 0 - spaces - Optional source and target spaces Returns ------- @@ -221,8 +217,6 @@ def from_linear_map( def identity( cls, ndim: int, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an identity affine transformation. @@ -230,8 +224,6 @@ def identity( ---------- ndim The dimensionality of the transform. - spaces - Optional source and target spaces Returns ------- @@ -244,8 +236,6 @@ def identity( def translation( cls, translation: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine translation. @@ -253,8 +243,6 @@ def translation( ---------- translation D-length array of translation values. - spaces - Optional source and target spaces Returns ------- @@ -277,8 +265,6 @@ def translation( def scaling( cls, scale: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine scaling. @@ -286,8 +272,6 @@ def scaling( ---------- scale D-length array of scaling factors. - spaces - Optional source and target spaces Returns ------- @@ -302,15 +286,13 @@ def scaling( s = as_floats(scale) if s.ndim != 1: raise ValueError(f"Scale array must be 1D; got shape {s.shape}") - return cls.from_linear_map(np.diag(s), spaces=spaces) + return cls.from_linear_map(np.diag(s)) @classmethod def reflection( cls, axis: Union[int, Container[int]], ndim: int, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine reflection. @@ -320,8 +302,6 @@ def reflection( A single axis or multiple to reflect in. ndim How many dimensions to work in. - spaces - Optional source and target spaces Returns ------- @@ -331,7 +311,7 @@ def reflection( if isinstance(axis, (int, np.integer)): axis = [axis] values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)]) - return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces) + return cls.from_linear_map(np.diag(values.astype(float))) @classmethod def rotation2( @@ -339,8 +319,6 @@ def rotation2( rotation: float, degrees: bool = True, clockwise: bool = False, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create a 2D affine rotation. @@ -352,8 +330,6 @@ def rotation2( Whether rotation is in degrees (rather than radians), by default True clockwise Whether rotation is clockwise, by default False - spaces - Optional source and target spaces Returns ------- @@ -365,7 +341,7 @@ def rotation2( if clockwise: rotation *= -1 c, s = math.cos(rotation), math.sin(rotation) - return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces) + return cls.from_linear_map(np.array([[c, -s], [s, c]])) @classmethod def rotation3( @@ -374,8 +350,6 @@ def rotation3( degrees: bool = True, clockwise: bool = False, order: tuple[int, int, int] = (0, 1, 2), - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create a 3D affine rotation. @@ -389,8 +363,6 @@ def rotation3( Whether rotation is clockwise, by default False order What order to apply the rotations, by default (0, 1, 2) - spaces - Optional source and target spaces Returns ------- @@ -425,15 +397,13 @@ def rotation3( np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]), ] rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]] - return cls.from_linear_map(rot, spaces=spaces) + return cls.from_linear_map(rot) @classmethod def shearing( cls, factor: Union[float, np.ndarray], ndim: int | None = None, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine shear. @@ -450,8 +420,6 @@ def shearing( Shear scale factors; see above for more details. ndim If factor is scalar, broadcast to this many dimensions, by default None - spaces - Optional source and target spaces Returns ------- @@ -481,7 +449,7 @@ def shearing( for row_idx in range(m.shape[0] - 1): if m[row_idx, col_idx] == 0: m[row_idx, col_idx] = next(it) - return cls.from_linear_map(m, spaces=spaces) + return cls.from_linear_map(m) def __eq__(self, other: object) -> bool: if not isinstance(other, Affine): diff --git a/src/transformnd/transforms/by_dimension.py b/src/transformnd/transforms/by_dimension.py index a47049f..88e644f 100644 --- a/src/transformnd/transforms/by_dimension.py +++ b/src/transformnd/transforms/by_dimension.py @@ -4,7 +4,7 @@ from ..base import Transform from ..util import ArrayT -from ..types import NDims, Spaces +from ..types import NDims class SubTransform[ArrayT]: @@ -69,8 +69,6 @@ def __init__( self, subtransforms: list[SubTransform[ArrayT]], fill_identity: int | None = None, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters @@ -80,8 +78,6 @@ def __init__( fill_identity If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions. e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`. - spaces - Optional source and target spaces Raises ------ diff --git a/src/transformnd/transforms/grid.py b/src/transformnd/transforms/grid.py new file mode 100644 index 0000000..4447339 --- /dev/null +++ b/src/transformnd/transforms/grid.py @@ -0,0 +1,43 @@ +from typing import Any, Protocol, Generic + +from array_api_compat import array_namespace +from transformnd.types import NDims +from ..base import Transform +from ..types import ArrayT + + +class Interpolator(Protocol, Generic[ArrayT]): + def __call__(self, x: ArrayT) -> ArrayT: ... + + +class GridInterpolation(Transform[ArrayT]): + """Coordinate transformation which applies a callable to each dimension. + + Intended for use with instances of `scipy.interpolate` interpolators, + but any callable which takes and returns an array of floats would work. + """ + + def __init__( + self, + interpolators: list[Interpolator[ArrayT]], + ): + """ + Parameters + ---------- + interpolators + One callable per dimension, in order. + Each one should take and return an array of floats. + """ + self.interpolators = interpolators + nd = len(interpolators) + super().__init__(NDims(nd, nd)) + + def apply(self, coords: Any) -> Any: + xp = array_namespace(coords) + coords = self._validate_coords(coords) + coords_t = xp.transpose(coords) + out_coords_t = xp.zeros_like(coords_t) + for in_col, out_col, interp in zip(coords_t, out_coords_t, self.interpolators): + out_col[:] = interp(in_col) + + return xp.transpose(out_coords_t) diff --git a/src/transformnd/transforms/moving_least_squares.py b/src/transformnd/transforms/moving_least_squares.py index e7e0ff8..1dd2f24 100644 --- a/src/transformnd/transforms/moving_least_squares.py +++ b/src/transformnd/transforms/moving_least_squares.py @@ -9,7 +9,7 @@ from typing import Self from ..base import Transform -from ..types import NDims, Spaces +from ..types import NDims from ..util import as_floats @@ -25,8 +25,6 @@ def __init__( self, source_control_points: np.ndarray, target_control_points: np.ndarray, - *, - spaces: Spaces = Spaces(None, None), ): """Non-rigid transforms powered by molesq package. @@ -37,8 +35,6 @@ def __init__( target_control_points NxD array of coordinates of the corresponding control points in the target (deformed) space. - spaces - Optional source and target spaces """ from molesq.transform import Transformer diff --git a/src/transformnd/transforms/reflection.py b/src/transformnd/transforms/reflection.py index 3306cd5..f60869d 100644 --- a/src/transformnd/transforms/reflection.py +++ b/src/transformnd/transforms/reflection.py @@ -8,7 +8,7 @@ from transformnd.transforms import Affine from ..base import Transform -from ..types import NDims, Spaces +from ..types import NDims from ..util import is_square @@ -137,8 +137,6 @@ def apply(self, coords: np.ndarray) -> np.ndarray: def from_points( cls, points: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Self: """Infer a single plane of reflection from a minimal number of points on it. @@ -146,8 +144,6 @@ def from_points( ---------- points NxD array of N points in D dimensions. N == D - spaces - Optional source and target spaces Returns ------- @@ -161,8 +157,6 @@ def from_axis( cls, axis: int | Sequence[int], origin: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Self: """Reflect around hyperplane(s) parallel with axes. @@ -172,8 +166,6 @@ def from_axis( Index (or indices) of axes in which to reflect. origin Point around which to reflect. - spaces - Optional source and target spaces Returns ------- diff --git a/src/transformnd/transforms/simple.py b/src/transformnd/transforms/simple.py index f5c50df..d10b6af 100644 --- a/src/transformnd/transforms/simple.py +++ b/src/transformnd/transforms/simple.py @@ -11,7 +11,7 @@ from array_api_compat import array_namespace from array_api_compat import device as xp_device from ..base import Transform -from ..types import NDims, Spaces +from ..types import NDims from ..util import ArrayT, as_floats from ..transforms.affine import Affine @@ -49,8 +49,6 @@ class Translate(Transform[ArrayT]): def __init__( self, translation: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ): """Simple translation. @@ -58,8 +56,6 @@ def __init__( ---------- translation Translation to apply in all dimensions, or each dimension. - spaces - Optional source and target spaces Raises ------ @@ -97,8 +93,6 @@ class Scale(Transform[ArrayT]): def __init__( self, scale: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ): """Simple scale transform. @@ -108,8 +102,6 @@ def __init__( ---------- scale Scaling to apply in all dimensions, or each dimension. - spaces - Optional source and target spaces Raises ------ diff --git a/src/transformnd/transforms/thinplate.py b/src/transformnd/transforms/thinplate.py index 3e58e3d..57ac5c9 100644 --- a/src/transformnd/transforms/thinplate.py +++ b/src/transformnd/transforms/thinplate.py @@ -10,7 +10,7 @@ from ..base import Transform from ..util import as_floats -from ..types import Spaces, NDims +from ..types import NDims logger = logging.getLogger(__name__) @@ -27,8 +27,6 @@ def __init__( self, source_control_points: np.ndarray, target_control_points: np.ndarray, - *, - spaces: Spaces = Spaces(None, None), ): """Non-rigid control point based transforms in 2/3D. @@ -41,8 +39,6 @@ def __init__( NxD array of control point coordinates in the source space. target_control_points NxD array of control point coordinates in the target (deformed) space. - spaces - Optional source and target spaces Raises ------ diff --git a/src/transformnd/util.py b/src/transformnd/util.py index da9c844..68963fc 100644 --- a/src/transformnd/util.py +++ b/src/transformnd/util.py @@ -12,8 +12,7 @@ ) import numpy as np -from .types import SpaceRef, ArrayT -from .constants import UNSPECIFIED_SPACE_NAME +from .types import ArrayT logger = logging.getLogger(__name__) @@ -134,13 +133,6 @@ def format_dims(supported: set[int] | None) -> str: return "/".join(f"{d}D" for d in sorted(supported)) -def space_str(space: SpaceRef | None) -> str: - if space is None: - return UNSPECIFIED_SPACE_NAME - else: - return str(space) - - def is_square(arr: ArrayT) -> bool: """Check whether an array is 2D and has the same number of rows as columns""" xp = array_namespace(arr) From 52e88770ec23840509a8e9af6d559d69ab0c64a6 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Tue, 7 Jul 2026 15:22:58 +0100 Subject: [PATCH 3/6] Remove spaces from tutorial --- CHANGELOG.md | 4 +++ examples/image.py | 30 +++++++++++------- examples/tutorial.py | 39 +++++++++-------------- src/transformnd/__init__.py | 3 +- src/transformnd/constants.py | 1 - src/transformnd/graph.py | 61 +++++++++++++----------------------- src/transformnd/types.py | 13 +------- 7 files changed, 61 insertions(+), 90 deletions(-) delete mode 100644 src/transformnd/constants.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 425fabb..fc3c34b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Removed + +- BREAKING: Transforms no longer have their own `spaces`; this concept only exist on the graph + ## 0.7.2 - 2026-06-25 ## 0.7.1 - 2026-06-25 diff --git a/examples/image.py b/examples/image.py index 6dab81d..f990266 100644 --- a/examples/image.py +++ b/examples/image.py @@ -17,6 +17,23 @@ def _(): and image transformation is simply a case of finding which source pixel to use for each output pixel. Here we take a 2-channel fluorescence microscopy image of some cells in 3 dimensions, use scaling information to map those pixels into a real-world space, and then map the pixels of our viewport into the the same space. + + We will refer to the following coordinate spaces and their axes: + + - cells + - Z: 0.29um + - C: membrane/ nuclei label intensity + - Y: 0.26um + - X: 0.26um + - world + - C: membrane/ nuclei label intensity + - Z: 1um + - Y: 1um + - X: 1um + - viewport + - Y: 1px + - X: 1px + - C: red/green/blue intensity """) return @@ -25,15 +42,6 @@ def _(): def _(): from skimage.data import cells3d - # ZCYX, (0.29um, membrane/nuclei channels, 0.26um, 0.26um) - CELLS_SPACE = "cells" - - # CZYX, (membrane/nuclei, um, um, um) - WORLD_SPACE = "world" - - # YXC image with RGB channels - VIEWPORT_SPACE = "viewport" - cells = cells3d() cells = cells.astype("float64") cells -= cells.min() @@ -43,11 +51,11 @@ def _(): print(f"{cells.dtype=}") print(f"{cells.min()=}") print(f"{cells.max()=}") - return CELLS_SPACE, VIEWPORT_SPACE, WORLD_SPACE, cells + return (cells,) @app.cell -def _(CELLS_SPACE, VIEWPORT_SPACE, WORLD_SPACE): +def _(): import transformnd as tnd from transformnd.transforms import ProjectAxis, MapAxis, Scale diff --git a/examples/tutorial.py b/examples/tutorial.py index 2b339cf..af4bcb6 100644 --- a/examples/tutorial.py +++ b/examples/tutorial.py @@ -193,17 +193,9 @@ def _(mo): A common task in coordinate transformation is to convert coordinates in one space (e.g. a pixel index of an image) into some other space (e.g. a location in "world" space, using the image's resolution and offset). - All `Transform`s can take the keyword arguments `spaces` on instantiation, which is a tuple of two hashable values (e.g. a string or number). - If not `None`, these values act as a reference to the spaces the transform goes from and to respectively. - This makes it easier to reason about what space the coordinates start and end in. - - `TransformSequence` instances check that consecutive transforms in the sequence refer to consistent spaces (if spaces are defined). - They will also use a transform with a defined target space to infer the source space of the next transform, if undefined. - - Once you have a set of transforms between different defined spaces, you can do bridging transforms: calculating how to get from one space to another by applying some subset of those transforms in sequence. + Given a set of transforms between known spaces, you can do bridging transforms: + calculating how to get from one space to another by applying some subset of those transforms in sequence. This uses the `TransformGraph` class (requires `networkx`). - - The `TransformGraph` will automatically unpack any transforms inside sequences (if they have spaces defined), and infer inverse transforms where possible. """) return @@ -211,23 +203,22 @@ def _(mo): @app.cell def _(Scale, Translate): from transformnd.graph import TransformGraph - from transformnd import Spaces g = TransformGraph() - ab = Scale([2, 2], spaces=Spaces("a", "b")) - bc = Translate([0.5, 1], spaces=Spaces("b", "c")) - bd = Translate([1, 0.5], spaces=Spaces("b", "d")) + ab = Scale([2, 2]) + bc = Translate([0.5, 1]) + bd = Translate([1, 0.5]) - g.add_transform(ab) - g.add_transform(ab.invert()) - g.add_transform(bc) - g.add_transform(bc.invert()) - g.add_transform(bd) - g.add_transform(bd.invert()) + g.add_transform(ab, "a", "b") + g.add_transform(ab.invert(), "b", "a") + g.add_transform(bc, "b", "c") + g.add_transform(bc.invert(), "c", "b") + g.add_transform(bd, "b", "d") + g.add_transform(bd.invert(), "d", "b") print("Transform sequence from a to c:", g.get_sequence("a", "c")) print("Transform sequence from d to a:", g.get_sequence("d", "a")) - return (Spaces,) + return @app.cell @@ -250,13 +241,13 @@ def _(mo): @app.cell -def _(Spaces, np): +def _(np): from transformnd import Transform, NDims class IsotropicScale2d(Transform): - def __init__(self, factor: float, *, spaces=Spaces(None, None)): + def __init__(self, factor: float): # ensure the spaces are handled properly - super().__init__(ndims=NDims(2, 2), spaces=spaces) + super().__init__(ndims=NDims(2, 2)) self.factor = factor def apply(self, coords: np.ndarray) -> np.ndarray: diff --git a/src/transformnd/__init__.py b/src/transformnd/__init__.py index 8bee1cc..26ccf1f 100644 --- a/src/transformnd/__init__.py +++ b/src/transformnd/__init__.py @@ -9,7 +9,7 @@ """ from .base import Transform, TransformSequence, TransformWrapper -from .types import Spaces, TransformSignature, NDims, SpaceRef +from .types import TransformSignature, NDims, SpaceRef from . import transforms from . import adapters from .graph import TransformGraph @@ -26,6 +26,5 @@ "SpaceRef", "transforms", "adapters", - "Spaces", "NDims", ] diff --git a/src/transformnd/constants.py b/src/transformnd/constants.py deleted file mode 100644 index d966cc2..0000000 --- a/src/transformnd/constants.py +++ /dev/null @@ -1 +0,0 @@ -UNSPECIFIED_SPACE_NAME = "???" diff --git a/src/transformnd/graph.py b/src/transformnd/graph.py index a4bfb68..30f68f1 100644 --- a/src/transformnd/graph.py +++ b/src/transformnd/graph.py @@ -14,8 +14,8 @@ from .transforms.bijection import Bijection from .base import Transform, TransformSequence -from .util import ArrayT, same_or_none -from .types import Spaces, SpaceRef +from .util import ArrayT +from .types import SpaceRef logger = logging.getLogger(__name__) @@ -32,7 +32,7 @@ def normalise_edge_weight_fn(w: str | WeightFn | None) -> WeightFn: return w -class TransformGraph[ArrayT]: +class TransformGraph[ArrayT, SpaceRef]: """Transform between any number of arbitrary spaces/ coordinate systems. Finds the shortest path for transforming one space @@ -52,25 +52,12 @@ def __init__( self.graph = nx.MultiDiGraph() self.space_ndims: dict[SpaceRef, int] = dict() - def _update_spaces( - self, - transform: Transform[ArrayT], - source: SpaceRef, - target: SpaceRef, - ) -> Spaces: - """Check that the transform's spaces do not conflict with those given explicitly, - that the source and target space is defined somewhere, - and that the dimensionality of the spaces (inferred from the transforms) - does not conflict with known spaces. - """ - # if the node already exists, make sure the dimensionality does not conflict - self.space_ndims[source] = same_or_none( - self.space_ndims.get(source), transform.ndims.source - ) - self.space_ndims[target] = same_or_none( - self.space_ndims.get(target), transform.ndims.target - ) - return Spaces(source, target) + def _add_space(self, space: SpaceRef, ndim: int): + curr_ndim = self.space_ndims.get(space) + if curr_ndim is None: + self.space_ndims[space] = ndim + elif curr_ndim != ndim: + raise ValueError(f"Space {space} is {curr_ndim}D, got {ndim}D") def _add_transform( self, @@ -81,8 +68,8 @@ def _add_transform( ) -> list[tuple[SpaceRef, SpaceRef]]: """Clearing the get_sequence cache and splitting sequences and bijections should be handled outside this method.""" out = [] - - src, tgt = self._update_spaces(transform, source, target) + self._add_space(source, transform.ndims.source) + self._add_space(target, transform.ndims.target) if edge_data is None: edge_data = dict() @@ -91,8 +78,8 @@ def _add_transform( raise ValueError(f"Must not use the key '{TRANSFORM_KEY}' in edge_data") d = {TRANSFORM_KEY: transform, **edge_data} - self.graph.add_edge(src, tgt, **d) - out.append((src, tgt)) + self.graph.add_edge(source, target, **d) + out.append((source, target)) return out def add_transform( @@ -197,9 +184,7 @@ def get_sequence( min(edges.values(), key=lambda d: wfn(src, tgt, d))[TRANSFORM_KEY] ) - seq = TransformSequence( - transforms, - ) + seq = TransformSequence(transforms) if not full: seq = seq.simplify(drop_inverse=True) return seq @@ -250,20 +235,16 @@ def __iter__(self) -> Iterator[Transform[ArrayT]]: Transform[ArrayT] The next transform in the graph. - Examples - -------- - Create a new transform graph using another - - >>> new_tgraph = TransformGraph([extra_transform, *old_tgraph]) - """ for _, _, t in self.graph.edges.data(TRANSFORM_KEY): yield t - def to_device( + def to_device[ArrayT2]( self, xp: ModuleType, device: str | None = None - ) -> TransformGraph[ArrayT]: - result: TransformGraph[ArrayT] = TransformGraph() - for src, tgt, t in self.graph.edges.data(TRANSFORM_KEY): - result.graph.add_edge(src, tgt, transform=t.to_device(xp, device)) + ) -> TransformGraph[ArrayT2, SpaceRef]: + result: TransformGraph[ArrayT2, SpaceRef] = TransformGraph() + for src, tgt, d in self.graph.edges.data(): + t: Transform[ArrayT] = d.pop(TRANSFORM_KEY) + t2: Transform[ArrayT2] = t.to_device(xp, device) # type: ignore + result.add_transform(t2, src, tgt, edge_data=d) return result diff --git a/src/transformnd/types.py b/src/transformnd/types.py index 3245086..64ff7a5 100644 --- a/src/transformnd/types.py +++ b/src/transformnd/types.py @@ -3,14 +3,12 @@ from typing_extensions import TypeVar import numpy as np -from .constants import UNSPECIFIED_SPACE_NAME - ArrayT = TypeVar("ArrayT", default=np.ndarray) type TransformSignature[ArrayT] = Callable[[ArrayT], ArrayT] """Type annotation of a function which can be used as a transform.""" -type SpaceRef = Hashable +SpaceRef = TypeVar("SpaceRef", default=Hashable) """Type annotation of identifiers which can be used to refer to spaces""" @@ -33,14 +31,5 @@ def __str__(self) -> str: return f"{self.source}->{self.target}" -class Spaces(SrcTgt[SpaceRef | None]): - """Source-target tuple for space identifiers.""" - - def __str__(self) -> str: - s = UNSPECIFIED_SPACE_NAME if self.source is None else self.source - t = UNSPECIFIED_SPACE_NAME if self.target is None else self.source - return f"{s}->{t}" - - class NDims(SrcTgt[int]): """Source-target tuple for numbers of dimensions.""" From e514647857226e57a3320905fc846ba3fa0235bd Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Tue, 7 Jul 2026 15:26:04 +0100 Subject: [PATCH 4/6] Fix to_device base impl --- README.md | 4 ++-- src/transformnd/base.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4149a31..594cfb5 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,11 @@ Methods which MUST be implemented: Methods which SHOULD be implemented if applicable: -- `to_device`: if any of the transformation's parameters need to be placed on a specific device (e.g. affine matrices on the GPU) +- `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`. - `is_identity`: if you can cheaply check whether your transformation is an identity transformation. The base class implementation returns `False`. - `to_affine`: if your transformation can be represented as an affine matrix. The base class implementation returns `None`. - `invert`: if your transformation can be inverted (default None if not) - - This automatically implements `__invert__` (the `~my_transform` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`. + - This automatically implements `__invert__` (the `~` operator), which returns `NotImplemented` (probably raising `NotImplementedError`) if `invert` would return `None`. ## Contributing diff --git a/src/transformnd/base.py b/src/transformnd/base.py index 82a6610..8c5fb82 100644 --- a/src/transformnd/base.py +++ b/src/transformnd/base.py @@ -133,10 +133,9 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: # noqa: Returns ------- Self - A new transform instance with parameters on the target device, - or NotImplemented if the subclass does not support device placement. + A new transform instance with parameters on the target device """ - return NotImplemented + return self def __or__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]: """Compose transformations into a sequence. From a8e2a179b8ce80bbbabc66173caad048a4c6dbb0 Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Wed, 8 Jul 2026 14:30:23 +0100 Subject: [PATCH 5/6] type bound on spaceref --- src/transformnd/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transformnd/types.py b/src/transformnd/types.py index 64ff7a5..de11542 100644 --- a/src/transformnd/types.py +++ b/src/transformnd/types.py @@ -8,7 +8,7 @@ type TransformSignature[ArrayT] = Callable[[ArrayT], ArrayT] """Type annotation of a function which can be used as a transform.""" -SpaceRef = TypeVar("SpaceRef", default=Hashable) +SpaceRef = TypeVar("SpaceRef", bound=Hashable, default=Hashable) """Type annotation of identifiers which can be used to refer to spaces""" From 19fd5ff1da2d357e02decbb2ecf33165865a799d Mon Sep 17 00:00:00 2001 From: Chris Barnes Date: Thu, 9 Jul 2026 15:20:12 +0100 Subject: [PATCH 6/6] More useful __str__ impls --- src/transformnd/base.py | 14 ++++++++------ src/transformnd/graph.py | 13 ++++--------- src/transformnd/transforms/bijection.py | 3 +++ src/transformnd/transforms/by_dimension.py | 8 +++++++- src/transformnd/transforms/grid.py | 4 ++++ src/transformnd/transforms/map_axis.py | 5 ++++- src/transformnd/transforms/project_axis.py | 10 ++++++++++ src/transformnd/transforms/simple.py | 11 ++++++++++- src/transformnd/transforms/vector_field.py | 12 +++++++++++- src/transformnd/types.py | 5 +++++ src/transformnd/util.py | 5 +++++ 11 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/transformnd/base.py b/src/transformnd/base.py index 8c5fb82..9337ff0 100644 --- a/src/transformnd/base.py +++ b/src/transformnd/base.py @@ -12,6 +12,7 @@ from .util import ( ArrayT, + join_strs, ) from itertools import pairwise @@ -181,9 +182,8 @@ def __ror__(self, other: Transform[ArrayT]) -> TransformSequence[ArrayT]: transforms, ) - # def __str__(self) -> str: - # cls_name = type(self).__name__ - # return f"{cls_name}" + def __str__(self) -> str: + return f"{type(self).__qualname__}@{hex(id(self))}[{self.ndims}]" class TransformWrapper(Transform[ArrayT]): @@ -216,6 +216,9 @@ def apply(self, coords: ArrayT) -> ArrayT: self._validate_coords(coords) return self.fn(coords) + def __str__(self) -> str: + return f"{super().__str__()}({self.fn})" + def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]: if isinstance(t, TransformSequence): @@ -303,9 +306,8 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: return result def __str__(self) -> str: - cls_name = type(self).__name__ - spaces_str = "|".join(str(t) for t in self.transforms) - return f"{cls_name}[{spaces_str}]" + spaces_str = join_strs(self.transforms, "|") + return f"{super().__str__()}({spaces_str})" def __getitem__(self, idx: slice | int): if isinstance(idx, int): diff --git a/src/transformnd/graph.py b/src/transformnd/graph.py index 30f68f1..646917d 100644 --- a/src/transformnd/graph.py +++ b/src/transformnd/graph.py @@ -213,7 +213,6 @@ def transform( or a function to determine a weight from the args `src_space, tgt_space, edge_data`, or None (all weights are 1). - Returns ------- ArrayT @@ -222,22 +221,18 @@ def transform( t = self.get_sequence(source_space, target_space, weight=weight) return t.apply(coords) - def __iter__(self) -> Iterator[Transform[ArrayT]]: + def __iter__(self) -> Iterator[tuple[SpaceRef, SpaceRef, Transform[ArrayT]]]: """Iterate through the transforms present in the graph. - Includes inferred reverse transforms. - N.B. the `__iter__` method of some popular graph libraries like networkx iterate through nodes, where this effectively iterates through edges. Yields ------ - Transform[ArrayT] - The next transform in the graph. - + tuple[SpaceRef, SpaceRef, Transform[ArrayT]] + The source space, target space, and transform. """ - for _, _, t in self.graph.edges.data(TRANSFORM_KEY): - yield t + yield from self.graph.edges.data(TRANSFORM_KEY) def to_device[ArrayT2]( self, xp: ModuleType, device: str | None = None diff --git a/src/transformnd/transforms/bijection.py b/src/transformnd/transforms/bijection.py index 792dbff..411d3e7 100644 --- a/src/transformnd/transforms/bijection.py +++ b/src/transformnd/transforms/bijection.py @@ -65,3 +65,6 @@ def to_affine(self) -> Affine[ArrayT] | None: return fwd return None + + def __str__(self) -> str: + return f"{super().__str__()}({self.forward},{self.inverse})" diff --git a/src/transformnd/transforms/by_dimension.py b/src/transformnd/transforms/by_dimension.py index 88e644f..a937009 100644 --- a/src/transformnd/transforms/by_dimension.py +++ b/src/transformnd/transforms/by_dimension.py @@ -3,7 +3,7 @@ from .simple import Identity from ..base import Transform -from ..util import ArrayT +from ..util import ArrayT, join_strs from ..types import NDims @@ -58,6 +58,9 @@ def __init__( self.transform = transform + def __str__(self) -> str: + return f"{join_strs(self.input_axes, surround='[]')}>{self.transform}>{join_strs(self.output_axes, surround='[]')}" + class ByDimension(Transform[ArrayT]): """Apply transformations to subsets of the coordinates' dimensions. @@ -150,3 +153,6 @@ def is_identity(self) -> bool: if t.input_axes != t.output_axes or not t.transform.is_identity(): return False return True + + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.subtransforms)})" diff --git a/src/transformnd/transforms/grid.py b/src/transformnd/transforms/grid.py index 4447339..354dc32 100644 --- a/src/transformnd/transforms/grid.py +++ b/src/transformnd/transforms/grid.py @@ -2,6 +2,7 @@ from array_api_compat import array_namespace from transformnd.types import NDims +from transformnd.util import join_strs from ..base import Transform from ..types import ArrayT @@ -41,3 +42,6 @@ def apply(self, coords: Any) -> Any: out_col[:] = interp(in_col) return xp.transpose(out_coords_t) + + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.interpolators)})" diff --git a/src/transformnd/transforms/map_axis.py b/src/transformnd/transforms/map_axis.py index b680165..e0b182f 100644 --- a/src/transformnd/transforms/map_axis.py +++ b/src/transformnd/transforms/map_axis.py @@ -3,7 +3,7 @@ import numpy as np from ..base import Transform -from ..util import ArrayT +from ..util import ArrayT, join_strs from ..types import NDims from ..transforms.affine import Affine @@ -61,3 +61,6 @@ def invert(self) -> Self | None: return type(self)( list(np.argsort(self.permutation)), ) + + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.permutation)})" diff --git a/src/transformnd/transforms/project_axis.py b/src/transformnd/transforms/project_axis.py index 69112a9..407544c 100644 --- a/src/transformnd/transforms/project_axis.py +++ b/src/transformnd/transforms/project_axis.py @@ -100,3 +100,13 @@ def invert(self) -> Self | None: source_ndim=self.ndims.target, target_ndim=self.ndims.source, ) + + def __str__(self) -> str: + op_str = "" + if self.dropped: + op_str += f"-[{','.join(str(s) for s in sorted(self.dropped))}]" + if self.created: + op_str += "," + if self.created: + op_str += f"+[{','.join(str(s) for s in sorted(self.created))}]" + return f"{super().__str__()}({op_str})" diff --git a/src/transformnd/transforms/simple.py b/src/transformnd/transforms/simple.py index d10b6af..c7b0a1a 100644 --- a/src/transformnd/transforms/simple.py +++ b/src/transformnd/transforms/simple.py @@ -12,7 +12,7 @@ from array_api_compat import device as xp_device from ..base import Transform from ..types import NDims -from ..util import ArrayT, as_floats +from ..util import ArrayT, as_floats, join_strs from ..transforms.affine import Affine @@ -42,6 +42,9 @@ def to_affine(self) -> Affine[ArrayT] | None: def apply(self, coords: ArrayT) -> ArrayT: return coords + def __str__(self) -> str: + return f"{super().__str__()}({self.ndims.source})" + class Translate(Transform[ArrayT]): """Translate coordinates by addition.""" @@ -86,6 +89,9 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: result.translation = xp.asarray(self.translation, device=device) return result + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.translation)})" + class Scale(Transform[ArrayT]): """Scale coordinates by multiplication.""" @@ -131,3 +137,6 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: result = copy(self) result.scale = xp.asarray(self.scale, device=device) return result + + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.scale)})" diff --git a/src/transformnd/transforms/vector_field.py b/src/transformnd/transforms/vector_field.py index 064ba55..f2a7885 100644 --- a/src/transformnd/transforms/vector_field.py +++ b/src/transformnd/transforms/vector_field.py @@ -8,7 +8,7 @@ from ..types import NDims from ..base import Transform, ArrayT -from ..util import set_scipy_array_api, as_floats +from ..util import join_strs, set_scipy_array_api, as_floats __all__ = ["Coordinates", "Displacements"] @@ -121,6 +121,16 @@ def to_device(self, xp: ModuleType, device: str | None = None) -> Self: coords = xp.asarray(self.vector_field, device) return type(self)(coords) + def __str__(self) -> str: + if self.index_transform is not None: + idx = f"idx={self.index_transform}," + + xp = array_namespace(self.vector_field) + sh = list(xp.shape(self.vector_field)) + sh.pop(self.vector_axis) + sh_str = join_strs(sh, "x") + return f"{super().__str__()}({idx},{sh_str})" + class Coordinates(BaseVectorField[ArrayT]): """Look up the output coordinates in an array. diff --git a/src/transformnd/types.py b/src/transformnd/types.py index de11542..9c3e188 100644 --- a/src/transformnd/types.py +++ b/src/transformnd/types.py @@ -28,8 +28,13 @@ def from_seq(cls, sequence: Sequence[T]) -> Self: return cls(sequence[0], sequence[1]) def __str__(self) -> str: + if self.source == self.target: + return str(self.source) return f"{self.source}->{self.target}" class NDims(SrcTgt[int]): """Source-target tuple for numbers of dimensions.""" + + def __str__(self) -> str: + return super().__str__() + "D" diff --git a/src/transformnd/util.py b/src/transformnd/util.py index 68963fc..33c1b58 100644 --- a/src/transformnd/util.py +++ b/src/transformnd/util.py @@ -1,5 +1,6 @@ """Utilities used elsewhere in the package.""" +from collections.abc import Iterable import os from types import ModuleType import warnings @@ -216,3 +217,7 @@ def set_scipy_array_api() -> bool: "SCIPY_ARRAY_API environment set but not '1'; certain transforms may not work with certain array types" ) return False + + +def join_strs(elems: Iterable, sep: str = ",", surround=("", "")) -> str: + return surround[0] + sep.join(str(e) for e in elems) + surround[1]