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/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/examples/image.py b/examples/image.py index b5e6655..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 @@ -60,7 +68,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 +82,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..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: @@ -267,7 +258,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/__init__.py b/src/transformnd/__init__.py index ae5b2e1..26ccf1f 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 TransformSignature, NDims, SpaceRef from . import transforms from . import adapters from .graph import TransformGraph @@ -27,6 +26,5 @@ "SpaceRef", "transforms", "adapters", - "Spaces", "NDims", ] diff --git a/src/transformnd/base.py b/src/transformnd/base.py index 666770b..9337ff0 100644 --- a/src/transformnd/base.py +++ b/src/transformnd/base.py @@ -11,14 +11,12 @@ from array_api_compat import array_namespace from .util import ( - SpaceRef, - same_or_none, - space_str, ArrayT, + join_strs, ) from itertools import pairwise -from .types import TransformSignature, Spaces, NDims +from .types import TransformSignature, NDims if TYPE_CHECKING: from .transforms import Affine @@ -30,19 +28,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.""" @@ -141,10 +134,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. @@ -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,10 @@ 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}]" + return f"{type(self).__qualname__}@{hex(id(self))}[{self.ndims}]" class TransformWrapper(Transform[ArrayT]): @@ -207,8 +194,6 @@ def __init__( fn: TransformSignature[ArrayT], in_ndim: int, out_ndim: int, - *, - spaces: Spaces = Spaces(None, None), ): """Wrapper around an arbitrary function. @@ -223,46 +208,16 @@ 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: self._validate_coords(coords) 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 __str__(self) -> str: + return f"{super().__str__()}({self.fn})" def as_transform_list(t: Transform[ArrayT]) -> list[Transform[ArrayT]]: @@ -278,8 +233,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 +244,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 +262,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 +293,6 @@ def invert(self) -> Transform[ArrayT] | None: return None return type(self)( transforms, - spaces=self.spaces.invert(), ) def apply(self, coords: ArrayT) -> ArrayT: @@ -360,42 +305,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()) - 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): @@ -418,7 +330,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 +373,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 +387,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/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 6c6bff9..646917d 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 +from .types import SpaceRef logger = logging.getLogger(__name__) @@ -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 @@ -61,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 @@ -81,41 +52,24 @@ def __init__( self.graph = nx.MultiDiGraph() self.space_ndims: dict[SpaceRef, int] = dict() - def _update_spaces( - self, - transform: Transform[ArrayT], - source: SpaceRef | None, - target: SpaceRef | None, - ) -> 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[tgt] = same_or_none( - self.space_ndims.get(tgt), transform.ndims.target - ) - return Spaces(src, tgt) + 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, 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.""" 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() @@ -124,15 +78,15 @@ 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( 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 +108,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. @@ -230,10 +184,7 @@ def get_sequence( min(edges.values(), key=lambda d: wfn(src, tgt, d))[TRANSFORM_KEY] ) - seq = TransformSequence( - transforms, - spaces=Spaces(source_space, target_space), - ) + seq = TransformSequence(transforms) if not full: seq = seq.simplify(drop_inverse=True) return seq @@ -262,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 @@ -271,33 +221,25 @@ 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. - - Examples - -------- - Create a new transform graph using another - - >>> new_tgraph = TransformGraph([extra_transform, *old_tgraph]) - + 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( + 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/transforms/affine.py b/src/transformnd/transforms/affine.py index 1933599..941763b 100644 --- a/src/transformnd/transforms/affine.py +++ b/src/transformnd/transforms/affine.py @@ -15,8 +15,8 @@ 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 ..types import NDims, Spaces +from ..util import as_floats, is_square +from ..types import NDims class Affine(Transform[ArrayT]): @@ -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]": @@ -185,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. @@ -197,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 ------- @@ -225,14 +211,12 @@ 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( cls, ndim: int, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an identity affine transformation. @@ -240,22 +224,18 @@ def identity( ---------- ndim The dimensionality of the transform. - spaces - Optional source and target spaces Returns ------- Affine[ArrayT] The identity affine transform. """ - return cls(np.eye(ndim + 1), spaces=spaces) + return cls(np.eye(ndim + 1)) @classmethod def translation( cls, translation: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine translation. @@ -263,8 +243,6 @@ def translation( ---------- translation D-length array of translation values. - spaces - Optional source and target spaces Returns ------- @@ -281,14 +259,12 @@ 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( cls, scale: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Affine[ArrayT]: """Create an affine scaling. @@ -296,8 +272,6 @@ def scaling( ---------- scale D-length array of scaling factors. - spaces - Optional source and target spaces Returns ------- @@ -312,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. @@ -330,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 ------- @@ -341,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( @@ -349,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. @@ -362,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 ------- @@ -375,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( @@ -384,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. @@ -399,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 ------- @@ -435,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. @@ -460,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 ------- @@ -491,12 +449,12 @@ 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): 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..411d3e7 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() @@ -78,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 10240c9..a937009 100644 --- a/src/transformnd/transforms/by_dimension.py +++ b/src/transformnd/transforms/by_dimension.py @@ -3,8 +3,8 @@ from .simple import Identity from ..base import Transform -from ..util import ArrayT -from ..types import NDims, Spaces +from ..util import ArrayT, join_strs +from ..types import NDims class SubTransform[ArrayT]: @@ -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. @@ -69,8 +72,6 @@ def __init__( self, subtransforms: list[SubTransform[ArrayT]], fill_identity: int | None = None, - *, - spaces: Spaces = Spaces(None, None), ): """ Parameters @@ -80,8 +81,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 ------ @@ -118,7 +117,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 +146,6 @@ def invert(self) -> Transform[ArrayT] | None: return type(self)( subtransforms=inverted_transforms, - spaces=self.spaces.invert(), ) def is_identity(self) -> bool: @@ -155,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 new file mode 100644 index 0000000..354dc32 --- /dev/null +++ b/src/transformnd/transforms/grid.py @@ -0,0 +1,47 @@ +from typing import Any, Protocol, Generic + +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 + + +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) + + 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 fc68f99..e0b182f 100644 --- a/src/transformnd/transforms/map_axis.py +++ b/src/transformnd/transforms/map_axis.py @@ -3,8 +3,8 @@ import numpy as np from ..base import Transform -from ..util import ArrayT -from ..types import Spaces, NDims +from ..util import ArrayT, join_strs +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,7 @@ def apply(self, coords: ArrayT) -> ArrayT: def invert(self) -> Self | None: return type(self)( list(np.argsort(self.permutation)), - spaces=self.spaces.invert(), ) + + def __str__(self) -> str: + return f"{super().__str__()}({join_strs(self.permutation)})" diff --git a/src/transformnd/transforms/moving_least_squares.py b/src/transformnd/transforms/moving_least_squares.py index 456c766..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 @@ -50,7 +46,6 @@ def __init__( s.shape[1], t.shape[1], ), - spaces=spaces, ) def apply(self, coords: np.ndarray) -> np.ndarray: @@ -70,5 +65,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..407544c 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,14 @@ def invert(self) -> Self | None: self.dropped, source_ndim=self.ndims.target, target_ndim=self.ndims.source, - spaces=self.spaces.invert(), ) + + 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/reflection.py b/src/transformnd/transforms/reflection.py index a520e42..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 @@ -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) @@ -141,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. @@ -150,23 +144,19 @@ def from_points( ---------- points NxD array of N points in D dimensions. N == D - spaces - Optional source and target spaces Returns ------- Self """ point, normals = get_hyperplanes(np.asarray(points), unitise=False) - return cls(normals, point, spaces=spaces) + return cls(normals, point) @classmethod def from_axis( cls, axis: int | Sequence[int], origin: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ) -> Self: """Reflect around hyperplane(s) parallel with axes. @@ -176,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 ------- @@ -204,7 +192,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..c7b0a1a 100644 --- a/src/transformnd/transforms/simple.py +++ b/src/transformnd/transforms/simple.py @@ -11,8 +11,8 @@ 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 ..util import ArrayT, chain_or, as_floats +from ..types import NDims +from ..util import ArrayT, as_floats, join_strs 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,22 +30,21 @@ 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 + def __str__(self) -> str: + return f"{super().__str__()}({self.ndims.source})" + class Translate(Transform[ArrayT]): """Translate coordinates by addition.""" @@ -55,8 +52,6 @@ class Translate(Transform[ArrayT]): def __init__( self, translation: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ): """Simple translation. @@ -64,8 +59,6 @@ def __init__( ---------- translation Translation to apply in all dimensions, or each dimension. - spaces - Optional source and target spaces Raises ------ @@ -77,12 +70,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,13 +82,16 @@ 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) 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.""" @@ -105,8 +99,6 @@ class Scale(Transform[ArrayT]): def __init__( self, scale: ArrayLike, - *, - spaces: Spaces = Spaces(None, None), ): """Simple scale transform. @@ -116,8 +108,6 @@ def __init__( ---------- scale Scaling to apply in all dimensions, or each dimension. - spaces - Optional source and target spaces Raises ------ @@ -127,10 +117,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,10 +131,12 @@ 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: 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/thinplate.py b/src/transformnd/transforms/thinplate.py index 9a558a8..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 ------ @@ -66,13 +62,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..f2a7885 100644 --- a/src/transformnd/transforms/vector_field.py +++ b/src/transformnd/transforms/vector_field.py @@ -6,9 +6,9 @@ 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 +from ..util import join_strs, set_scipy_array_api, as_floats __all__ = ["Coordinates", "Displacements"] @@ -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,17 @@ 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) + + 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]): @@ -144,8 +150,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 +167,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 +197,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 +209,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 +221,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/src/transformnd/types.py b/src/transformnd/types.py index 3245086..9c3e188 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", bound=Hashable, default=Hashable) """Type annotation of identifiers which can be used to refer to spaces""" @@ -30,17 +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 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.""" + + def __str__(self) -> str: + return super().__str__() + "D" diff --git a/src/transformnd/util.py b/src/transformnd/util.py index da9c844..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 @@ -12,8 +13,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 +134,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) @@ -224,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] 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):