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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 19 additions & 13 deletions examples/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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

Expand All @@ -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)

Expand All @@ -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
Expand Down
40 changes: 15 additions & 25 deletions examples/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,41 +193,32 @@ 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


@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
Expand All @@ -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:
Expand All @@ -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
Expand Down
4 changes: 1 addition & 3 deletions src/transformnd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +26,5 @@
"SpaceRef",
"transforms",
"adapters",
"Spaces",
"NDims",
]
Loading
Loading