Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d94abfd
Transformation manager foundation (#1164)
ajkswamy Aug 10, 2026
5aaf4ec
Creates the graph module based on the Ngff* classes
Tomaz-Vieira Aug 21, 2026
2379e27
addresses some PR comments, simplifies MapAxis
Tomaz-Vieira Sep 2, 2026
0d4140b
Adds ProjectAxisEdge, makes inverse return optional
Tomaz-Vieira Sep 3, 2026
a0582e8
Moves exceptions to exceptions.py, edge names default to None
Tomaz-Vieira Sep 4, 2026
9d9315b
Use custom exceptions everywhere, adds Raises to docstrings
Tomaz-Vieira Sep 4, 2026
96066f3
Fix Affine.from_affine_matrix slicing]
Tomaz-Vieira Sep 8, 2026
b15281e
Fix coords bug when reading from OMEZarrMultiscale. Some cleanup
Tomaz-Vieira Sep 8, 2026
4cba32e
Adds test for parsing OMEZarrMultiscale
Tomaz-Vieira Sep 8, 2026
c733658
Adds transformations tests, fixes some slicing bugs
Tomaz-Vieira Sep 9, 2026
8ee025e
Adds basic .sel test to parsed multiscale
Tomaz-Vieira Sep 9, 2026
ad1d809
Removes unused class
Tomaz-Vieira Sep 9, 2026
99ecb22
Adds more slicing tests to parsed multiscales
Tomaz-Vieira Sep 9, 2026
7361863
Adds/fixes comments in try_parse_ngff06_multiscales
Tomaz-Vieira Sep 9, 2026
0718fb7
Removes to_model methods for now
Tomaz-Vieira Sep 9, 2026
df63609
Moves AxisParsingException to exceptions.py
Tomaz-Vieira Sep 9, 2026
76f7c6b
Moves AxisParsingException back into vert.py to prevent circular imports
Tomaz-Vieira Sep 9, 2026
4f725b1
Fixes docstrings, list exceptions in "Raises"
Tomaz-Vieira Sep 9, 2026
9591fa2
Incorporates parse_project_axis
Tomaz-Vieira Sep 9, 2026
17eae96
Fixes parsing translatiion model getting bad name
Tomaz-Vieira Sep 9, 2026
3a42bef
Adds test for parsing *Edge transforms from ngff
Tomaz-Vieira Sep 9, 2026
2849a29
Fixes dependencies on ome-zarr and ome-zarr-models
Tomaz-Vieira Sep 11, 2026
4c6c8d9
Cleans up transform_points for rotation and affine
Tomaz-Vieira Sep 11, 2026
0c6d7b1
Fixes renamed fields in ome-zarr-models
Tomaz-Vieira Sep 11, 2026
113cf7d
Use frozen dataclasses for CoordSystems and Axis. Fix MapAxisEdge
Tomaz-Vieira Sep 10, 2026
876c176
Enforces Rotation matrix is orthonormal with det==1
Tomaz-Vieira Sep 10, 2026
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,14 @@ plans/
.coverage
htmlcov/

# local hatch config
hatch.toml

# Kilo and plans
.kilo/
plans/

# test coverage
.coverage
htmlcov/

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ dependencies = [
"networkx",
"numba>=0.55",
"numpy",
"ome-zarr>=0.16",
"ome-zarr>=0.19.2",
"ome-zarr-models>=1.8",
"pandas",
"pooch",
"pyarrow",
Expand Down
64 changes: 64 additions & 0 deletions src/spatialdata/_core/transformation_manager/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from spatialdata._types import ArrayLike
from spatialdata.transformations.graph.vert import Axis, CoordSystem
from spatialdata.transformations.ngff.ngff_coordinate_system import NgffCoordinateSystem


Expand Down Expand Up @@ -227,3 +229,65 @@ class TransformationManagerWarning(UserWarning):
"""Base warning category for TransformationManager."""

pass


class IncompatibleCoordSystemsError(Exception):
def __init__(self, input: CoordSystem, output: CoordSystem, message: str | None = None) -> None:
self.input = input
self.output = output
super().__init__(message or "Output axes can't be mapped to input axes")


class MissingAxisError(Exception):
def __init__(self, axis: Axis, cs: CoordSystem) -> None:
self.axis = axis
self.cs = cs
super().__init__(f"Axis {axis.name} is not in coordinate system {cs.name}")


class UnexpectedShapeError(Exception):
def __init__(
self,
*,
array_shape: tuple[int, ...],
expected_shape: tuple[int, ...] | str | None = None,
array_name: str | None = None,
) -> None:
self.array_shape = array_shape
self.expected_shape = expected_shape
message = "Unexpected array shape"
if array_name is not None:
message += f"for '{array_name}'"
message += f": {array_shape}"
if expected_shape is not None:
message += f" instead of {expected_shape}"
super().__init__(message)


class DeterminantDifferentFromOne(Exception):
def __init__(self, matrix: ArrayLike) -> None:
self.matrix = matrix
super().__init__("Matrix does not have det(M) == 1")


class NotOrthonormalError(Exception):
def __init__(self, matrix: ArrayLike) -> None:
self.matrix = matrix
super().__init__("Matrix is not orthonormal")


class EmptyTransformSequenceError(Exception):
def __init__(self) -> None:
super().__init__("Empty sequence of transformations")


class AxisRedefinitionError(Exception):
def __init__(self, axis: Axis) -> None:
super().__init__(f"Axis {axis.name} is defined multiple times")


class UnmappedAxisError(Exception):
def __init__(self, axis: Axis, cs: CoordSystem) -> None:
self.axis = axis
self.cs = cs
super().__init__(f"Axis {axis.name} from coordinate system {cs.name} is not mapped to anything")
105 changes: 100 additions & 5 deletions src/spatialdata/_io/io_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import dask.array as da
import numpy as np
import ome_zarr as oz
import ome_zarr_models.v06.coordinate_transforms as ozm06trans
import xarray as xr
import zarr
from ome_zarr.format import Format
from ome_zarr.io import ZarrLocation
Expand All @@ -18,6 +21,7 @@
from ome_zarr.writer import write_multiscale as write_multiscale_ngff
from ome_zarr.writer import write_multiscale_labels as write_multiscale_labels_ngff
from xarray import DataArray, DataTree
from xarray.indexes import RangeIndex

from spatialdata._io._utils import (
_get_transformations_from_ngff_dict,
Expand All @@ -30,7 +34,7 @@
RasterFormatType,
get_ome_zarr_format,
)
from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER, GROUP_NAME
from spatialdata._types import ELEMENT_TYPE, ELEMENT_TYPE_RASTER
from spatialdata._utils import get_pyramid_levels
from spatialdata.models.models import ATTRS_KEY
from spatialdata.models.pyramids_utils import dask_arrays_to_datatree
Expand All @@ -40,6 +44,8 @@
_set_transformations,
compute_coordinates,
)
from spatialdata.transformations.graph.edge import BaseTransformationEdge, parse_ngff_transf
from spatialdata.transformations.graph.vert import Axis, CoordSystem


def _is_flat_int_sequence(value: object) -> TypeGuard[Sequence[int]]:
Expand Down Expand Up @@ -162,6 +168,97 @@ def _prepare_storage_options(
return prepared_options


def try_read_ngff06_multiscale(store: Path) -> tuple[DataTree, Sequence[BaseTransformationEdge]]:
multiscale = oz.OMEZarrMultiscale.from_ome_zarr(str(store))
assert isinstance(multiscale, oz.OMEZarrMultiscale) # disambiguate from OMEZarrLabel
return try_parse_ngff06_multiscale(multiscale)


def try_parse_ngff06_multiscale(multiscale: oz.OMEZarrMultiscale) -> tuple[DataTree, Sequence[BaseTransformationEdge]]:
"""Parse an OMEZarMultiscale into a DataTree and collects Multiscale-level transforms."""
name_to_cs: dict[str, CoordSystem] = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder: later when we work with Scenes, the coordinate system name is not enough for uniquely identifying a CS. It will be the combniation of path where the cs is defined, and the name.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw later in that in vert.py there is already some logic needed for this (the CoordinateSystemIndentifier usage.

for cs in multiscale.metadata.coordinateSystems or ():
parsed_cs = CoordSystem.try_from_model(cs)
name_to_cs[cs.name] = parsed_cs

parsed_transfs: list[BaseTransformationEdge] = []
for transf in multiscale.metadata.coordinateTransformations or ():
in_cs_id = transf.input
out_cs_ref = transf.output
# these should not be None as per the spec
assert in_cs_id is not None
assert out_cs_ref is not None

# FIXME: not handling references into labels yet, which use name and path
in_cs_name = in_cs_id.name
out_cs_name = out_cs_ref.name
assert in_cs_name is not None
assert out_cs_name is not None

# assume CS references are valid via ome-zarr(-models)-py
input = name_to_cs[in_cs_name]
output = name_to_cs[out_cs_name]
parsed = parse_ngff_transf(input=input, output=output, model=transf)
parsed_transfs.append(parsed)

omero = multiscale.omero
channel_names = None if omero is None else [d.color for d in omero.channels]

data_tree = xr.DataTree()
for scale_idx, (ds_md, ds) in enumerate(zip(multiscale.metadata.datasets, multiscale.images, strict=True)):
transf = ds_md.coordinateTransformations[0]

intrinsic_cs = name_to_cs[multiscale.metadata.intrinsic_coordinate_system.name]
assert transf.input is not None
assert transf.input.path is not None

# This coord system doesn't exist explicitly in the NGFF file, nor will
# it exist in our graph of transformations; It is only created here
# for the sake of creating the transformations that will be expressed
# in levels of a xr.DataTree
pixel_cs = CoordSystem(
name=transf.input.path,
axes=tuple(Axis(name=ax.name, type=ax.type) for ax in intrinsic_cs.axes),
virtual=True,
)

pixel_cs_to_intrinsic_ngff = ozm06trans.Sequence(transformations=ds_md.coordinateTransformations)
seq = parse_ngff_transf(input=pixel_cs, output=intrinsic_cs, model=pixel_cs_to_intrinsic_ngff)
ds_shape = np.asarray(ds.data.shape)
transformed_start = seq.transform_points(np.zeros_like(ds_shape)[np.newaxis, :])[0]
transformed_stop = seq.transform_points((ds_shape - 1)[np.newaxis, :])[0]

coords: xr.Coordinates = xr.Coordinates()
for low, high, ax, extent in zip(transformed_start, transformed_stop, intrinsic_cs.axes, ds_shape, strict=True):
if ax.type == "channel" and channel_names is not None:
coords = coords.merge({ax.name: channel_names}).coords
else:
axis_index = xr.Coordinates.from_xindex(
RangeIndex.linspace(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good

start=low,
stop=high,
num=extent,
endpoint=True,
dim=ax.name,
)
)
coords = coords.merge(axis_index).coords

# Note: the magic "image" and "scale<N> " strings mimic the current
# behavior from `dask_arrays_to_datatree`
data_tree[f"scale{scale_idx}"] = xr.Dataset(
{
"image": xr.DataArray(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR (we can "resolve the conversation"), but a reminder. This string and the name="image" below needs to be documented somewhere, e.g. in docstrings of model classes/contribution guide for developers using spaitaldata/in-memory design doc.

E.g. will the users expect to have always image or any string, but always a len(dataset) == 1? The syntax to retrieve the DataArray from the DataTree will change.

CC @jan-glx

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string and the name="image" below needs to be documented somewhere

Yup, I agree. I am replicating the current behavior, but I'd really rather have it not use magic strings at all, even if well documented

ds.data,
name="image",
dims=intrinsic_cs.axes_names,
coords=coords,
)
},
)
return data_tree, parsed_transfs


def _read_multiscale(
store: str | Path, raster_type: ELEMENT_TYPE_RASTER, reader_format: Format
) -> DataArray | DataTree:
Expand Down Expand Up @@ -267,9 +364,7 @@ def _get_multiscale_nodes(image_nodes: list[Node], nodes: list[Node]) -> list[No
return nodes


def _get_raster_element_group(
raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str
) -> zarr.Group:
def _get_raster_element_group(raster_type: ELEMENT_TYPE_RASTER, group: zarr.Group, element_name: str) -> zarr.Group:
"""Get the Zarr group holding a raster element that has just been written.

Labels are nested one level deeper than images: ome-zarr writes them inside a "labels" group, so for them the
Expand All @@ -288,7 +383,7 @@ def _get_raster_element_group(
-------
The Zarr group of the raster element.
"""
if raster_type != "labels":
if raster_type != ELEMENT_TYPE.LABELS:
return group
labels_group = group["labels"]
if not isinstance(labels_group, zarr.Group):
Expand Down
2 changes: 1 addition & 1 deletion src/spatialdata/_io/io_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ def _get_groups_for_element(
The Zarr groups for the root, element_type and element for a specific element.
"""
if not isinstance(zarr_path, Path):
raise ValueError("zarr_path should be a Path object")
raise TypeError("zarr_path should be a Path object")

if element_type not in [
"images",
Expand Down
9 changes: 5 additions & 4 deletions src/spatialdata/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
from xarray import DataArray, DataTree

__all__ = [
"ELEMENT_TYPE",
"ELEMENT_TYPE_RASTER",
"ELEMENT_TYPE_VECTOR",
"GROUP_NAME",
"ArrayLike",
"ColorLike",
"DTypeLike",
"JSONValue",
"Raster_T",
"ELEMENT_TYPE",
"ELEMENT_TYPE_RASTER",
"ELEMENT_TYPE_VECTOR",
"GROUP_NAME",
]

from numpy.typing import DTypeLike, NDArray
Expand All @@ -32,6 +32,7 @@
type Raster_T = DataArray | DataTree
ColorLike = tuple[float, ...] | str


# A value that survives a round-trip through JSON, which is the invariant that `SpatialData.attrs` must satisfy: the
# attrs are persisted with `zarr.Group.attrs.put()`, which rejects anything that is not JSON-serializable (e.g. numpy
# arrays, sets, DataFrames). Note that JSON has no tuples and only string keys, so a tuple is read back as a list and
Expand Down
7 changes: 6 additions & 1 deletion src/spatialdata/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
from xarray import DataArray, Dataset, DataTree

from spatialdata._types import ArrayLike, ListOrNDArrayFloating
from spatialdata.transformations import Sequence, Translation, get_transformation, set_transformation
from spatialdata.transformations import (
Sequence,
Translation,
get_transformation,
set_transformation,
)

RT = TypeVar("RT")

Expand Down
2 changes: 2 additions & 0 deletions src/spatialdata/transformations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from spatialdata.transformations import graph
from spatialdata.transformations.operations import (
align_elements_using_landmarks,
get_transformation,
Expand All @@ -20,6 +21,7 @@
)

__all__ = [
"graph",
"BaseTransformation",
"Identity",
"MapAxis",
Expand Down
1 change: 1 addition & 0 deletions src/spatialdata/transformations/graph/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading