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
f57dc1d
Creates the graph module based on the Ngff* classes
Tomaz-Vieira Aug 21, 2026
b351aa0
addresses some PR comments, simplifies MapAxis
Tomaz-Vieira Sep 2, 2026
1d4d7cb
Adds ProjectAxisEdge, makes inverse return optional
Tomaz-Vieira Sep 3, 2026
2a8bc97
Update ome-zarr dependency
Tomaz-Vieira Sep 4, 2026
868c950
Moves exceptions to exceptions.py, edge names default to None
Tomaz-Vieira Sep 4, 2026
7565622
Use custom exceptions everywhere, adds Raises to docstrings
Tomaz-Vieira Sep 4, 2026
541335f
Fix Affine.from_affine_matrix slicing]
Tomaz-Vieira Sep 8, 2026
9d56ff2
Fix coords bug when reading from OMEZarrMultiscale. Some cleanup
Tomaz-Vieira Sep 8, 2026
675820d
Adds test for parsing OMEZarrMultiscale
Tomaz-Vieira Sep 8, 2026
7fa8b26
Adds transformations tests, fixes some slicing bugs
Tomaz-Vieira Sep 9, 2026
2bd6eee
Adds basic .sel test to parsed multiscale
Tomaz-Vieira Sep 9, 2026
9db6798
Removes unused class
Tomaz-Vieira Sep 9, 2026
5538c95
Adds more slicing tests to parsed multiscales
Tomaz-Vieira Sep 9, 2026
fcbd639
Adds/fixes comments in try_parse_ngff06_multiscales
Tomaz-Vieira Sep 9, 2026
1cd92c8
Removes to_model methods for now
Tomaz-Vieira Sep 9, 2026
8963e04
Moves AxisParsingException to exceptions.py
Tomaz-Vieira Sep 9, 2026
5d197cc
Moves AxisParsingException back into vert.py to prevent circular imports
Tomaz-Vieira Sep 9, 2026
ca0bac1
Fixes docstrings, list exceptions in "Raises"
Tomaz-Vieira Sep 9, 2026
4b2479f
Incorporates parse_project_axis
Tomaz-Vieira Sep 9, 2026
83c91df
Fixes parsing translatiion model getting bad name
Tomaz-Vieira Sep 9, 2026
79307e3
Adds test for parsing *Edge transforms from ngff
Tomaz-Vieira Sep 9, 2026
d4287bb
Use frozen dataclasses for CoordSystems and Axis. Fix MapAxisEdge
Tomaz-Vieira Sep 10, 2026
a17cb69
Enforces Rotation matrix is orthonormal with det==1
Tomaz-Vieira Sep 10, 2026
367b16e
Fixes dependencies on ome-zarr and ome-zarr-models
Tomaz-Vieira Sep 11, 2026
c2d1e5a
Cleans up transform_points for rotation and affine
Tomaz-Vieira Sep 11, 2026
84e3669
Fixes renamed fields in ome-zarr-models
Tomaz-Vieira Sep 11, 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
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ dependencies = [
"networkx",
"numba>=0.55.0",
"numpy",
"ome_zarr>=0.16.0",
"ome_zarr>=0.19.2",
"ome-zarr-models>=1.8.0",
"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")
97 changes: 97 additions & 0 deletions src/spatialdata/_io/io_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 @@ -17,6 +20,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 @@ -38,6 +42,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 @@ -160,6 +166,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
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