Skip to content

NGFF v06 transformations - #1182

Draft
Tomaz-Vieira wants to merge 1 commit into
transformation_managerfrom
feature/ngff_v06_transforms
Draft

NGFF v06 transformations#1182
Tomaz-Vieira wants to merge 1 commit into
transformation_managerfrom
feature/ngff_v06_transforms

Conversation

@Tomaz-Vieira

@Tomaz-Vieira Tomaz-Vieira commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Mirrors Ngff* classes in the graph module

CoordSystem (based on NgffCoordinateSystem)

  • Made immutable, so we can __hash__ (needed for usage in graph, but we might still have the identity issue when searching for verts)
  • Can be marked "virtual" so that we know not to serialize it (that is, it has been created for the sake of putting it in a graph and maintaining the invariant of always having an input and output, but it should not show up in the zarr metadata)

*Edge (based on NgffTransformation)

  • All transformations always have input and output, which diverge from the raw NGFF
  • Don't do serialization to raw JSON anymore, but rather convert to and from omz-models-py classes
  • Stronger invariants; checked in __init__ and nowhere else
  • A few corner cases fixed

io_raster.py

  • Adds function to read multiscales from v06, applying the scale/translation transformations as a RangeIndex to the resulting xarrays

TODO

  • Add/fix tests

Cleans up and modernizes the NGff classes so that they have stronger
invariants and guarantee that they always have input and output.

Reading and writing to zarr is done via ome-zarr-models-py.

Uses graph module as part of io_raster.py::try_read_ngff06_multiscale
to interpret Ngff trnasformations and produce an output that could be
added to the new graph implementation.
@Tomaz-Vieira
Tomaz-Vieira force-pushed the feature/ngff_v06_transforms branch from 0dfccb5 to f57dc1d Compare August 21, 2026 11:33
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 30.46729% with 372 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (transformation_manager@b0ff71c). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/spatialdata/transformations/graph/edge.py 27.10% 277 Missing ⚠️
src/spatialdata/transformations/graph/vert.py 49.52% 53 Missing ⚠️
src/spatialdata/_io/io_raster.py 14.28% 42 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                    @@
##             transformation_manager    #1182   +/-   ##
=========================================================
  Coverage                          ?   88.86%           
=========================================================
  Files                             ?       55           
  Lines                             ?     8595           
  Branches                          ?        0           
=========================================================
  Hits                              ?     7638           
  Misses                            ?      957           
  Partials                          ?        0           
Files with missing lines Coverage Δ
src/spatialdata/transformations/__init__.py 100.00% <100.00%> (ø)
src/spatialdata/_io/io_raster.py 75.28% <14.28%> (ø)
src/spatialdata/transformations/graph/vert.py 49.52% <49.52%> (ø)
src/spatialdata/transformations/graph/edge.py 27.10% <27.10%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread pyproject.toml
"numpy",
"ome_zarr>=0.16.0",
# "ome_zarr>=0.16.0",
"ome_zarr @ git+https://github.com/ome/ome-zarr-py/@e859b55425b740335309876bb023fe52977a3045",

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.

this commit is ~40 commits old wrt main; I'd update already.

multiscale = oz.OMEZarrMultiscale.from_ome_zarr(str(store))
assert isinstance(multiscale, oz.OMEZarrMultiscale) # disambiguate from OMEZarrLabel

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.

continue
coords = coords.merge(
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

assert transf.input.path is not None
in_cs = CoordSystem(name=str(transf.input.name), axes=[Axis(name=ax.name, type=ax.type) for ax in out_cs.axes])

ozm_seq = ozm06trans.Sequence(transformations=ds_md.coordinateTransformations)

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.

IIRC, according to the specs, here we either have a Sequence (of Scale + Translation) or a Scale. We need to double check.

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.

Anyway the logic of doing .transform_points() is correct in both cases


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

Comment on lines +10 to +11
class AxisParsingException(Exception):
pass

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 like it here. Just for awareness, in the transformation manager codebase the exceptions are in a separate exceptions.py file. https://github.com/scverse/spatialdata/pull/1164/changes

We can choose to keep things local unless there are many exceptions, in that case it is better to place in a dedicated files.

Comment on lines +73 to +74
if not isinstance(model.unit, str):
raise AxisParsingException("Can't handle axis unit")

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.

Something to keep in mind for when we'll try to read legacy data. In the old CS we were settings unit="unit". This is not allowed by the specs (having "unit" is a SHOULD, but if they are specified they need to be real units).

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.

But also here, shouldn't we allow for model.unit to be None?

name=name,
type=model.type,
unit=model.unit,
long_name=model.longName,

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.

why is here camel case, and elsewhere with underscore?

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.

ah it's the ozm one!

Comment on lines +120 to +121
class LegacyAxes:
pass

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.

unused for now; placeholder for later?

Comment on lines +127 to +128
if len(self.axes) != len({axis.name for axis in self.axes}):
raise ValueError("Axes names must be unique")

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.

Can we delegate all the validation to ozm, including this one?

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.

Another issue with delegating to ozm is that it can only validate based on their own models, so we'd have to convert e.g. our Axis type into their ozm.Axis to do the validation, which is also kinda clunky 🤔

)

@classmethod
def try_from_model_or_default[T](cls, model: ozi.CoordinateSystem | None, *, default: T) -> CoordSystem | T:

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.

Who is going to use this/how?

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.

The logic for ensuring that default has unique names is something that would live outside this class. I wonder if then even the whole 3-line implementation of this function should live outside.

from collections.abc import Sequence
from typing import Final, Literal

import ome_zarr.classes.image as ozi

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.

How about being more explicit and import directly from ome-zarr-models-py? WDYT?

https://github.com/ome/ome-zarr-py/blob/025e83da618ec9e74f4b41f05814fce07b029356/ome_zarr/classes/image.py#L16

input = self.to_model()
if input is None:
return None
return ozi.CoordinateSystemIdentifier(name=input.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.

ok good, this is already the foundation for when we'll deal with Scenes

from spatialdata.transformations.graph.vert import Axis, CoordSystem


class GarbledInput(Exception):

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.

:D

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.

good to have the exception


@abstractmethod
def to_affine(self) -> AffineEdge:
"""Convert the transformation to an affine transformation, whenever the conversion can be made."""

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.

Up to you if doing it now or later, but I think it is going to be very beneficial to already play around with the displacement class to test code paths where a walk in the transformation graph contains a "non-affinable-transformation".

def to_affine(self) -> AffineEdge:
"""Convert the transformation to an affine transformation, whenever the conversion can be made."""

def _validate_transform_points_shapes(self, points: xr.DataArray | xr.DataTree | ArrayLike) -> None:

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.

why datatree and dataarray here? I think datatree should not be here, and dataarray should not be used for images. Maybe it is to use this as an arraylike with annotated coordinates (e.g. n x dims, with dims for example ['x', 'y', 'k', 'u'])?


def _validate_transform_points_shapes(self, points: xr.DataArray | xr.DataTree | ArrayLike) -> None:
"""
Validate if the shape of the points (coordinats to be transformed) are consistent with the input size of the

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.

mini typo

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.

coordinats -> coordinates

Comment on lines +77 to +78
f"points must be a tensor of shape (n, d), where n is the number of points and d is the "
f"the number of spatial dimensions. Points shape: {points.shape}, input size: {input_size}"

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.

NGFF is more general: the matrix also contains the non-spatial dimensions. The dimensionality constraint to check is the same, but we should not tell the user about spatial dimensions, just dimensions

Comment on lines +81 to +99
# order of the composition: self is applied first, then the transformation passed as argument
def compose_with(self, transformation: BaseTransfEdge) -> BaseTransfEdge:
"""
Compose the transfomation object with another transformation

Parameters
----------
transformation
The transformation to compose with.

Returns
-------
The compoesed transformation.

Notes
-------
Self is applied first, then the transformation passed as argument.
"""
return SequenceEdge([self, transformation], name=None) # FIXME: no 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 think I had this in the old NGFF classes but never used. Also, nitpick, this would lead to a transformation like [[[t0, t1], t2], t3]; which is fine, but composing in one go from outside may be more appealing [t0, t1, t2, t3].

Anyway, no big deal, with can keep.

@LucaMarconato LucaMarconato Aug 21, 2026

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.

Regarding the name: it is optional, so we can keep it optional (or let the user pass it as optional argument to compose_with())


def __init__(
self,
name: str | None,

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.

why not = None?

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.

after all the coordinate system name is crucial, the transformation name is not a big deal

Comment on lines +117 to +118
linear: ArrayLike,
translation: ArrayLike | None = None,

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.

This removes all the ambiguity of having to pass the homogenous affine matrix vs not.

input: CoordSystem,
output: CoordSystem,
) -> AffineEdge:
"""Creates an AffineEdge from a raw affine matrix

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'd add "in homogenous coordinates"

return SequenceEdge([self, transformation], name=None) # FIXME: no name?

@abstractmethod
def to_model(self) -> ozm06trans.AnyTransform:

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.

shall we add try_from_model as abstractmethod as well? this would mirror what done in vert.py

@Tomaz-Vieira Tomaz-Vieira Aug 21, 2026

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.

It would look nice and symmetrical, but it's not sound from a typing perspective; If we make it an abstract class method, then all child methods have to have a compatible signature, but each class deserializes from a specific model (e.g.: ScaleEdge deserializes from omz.Scale, IdentityEdge deserializes from omz.Identity, etc.), but child methods can't have stricter parameters than their parents.

Serializing in to_model is fine, though, because the child methods are free to have more precise return types than the parent method.

Comment on lines +304 to +305
if len(set(output_to_input.values())) != len(output_to_input):
raise ValueError("input_to_output must map unique inputs to unique outputs")

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.

Do we want to do this validation here instead of relying on ozmp? Added discussion in Zulip. Let's follow up there.

Comment on lines +310 to +313
def __repr__(self) -> str:
s = super().__repr__() + "\n"
s += "\n".join(f" {out} <- {inp}\n" for out, inp in self.output_to_input.items())
return s

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.

Rough edge: MapAxis is basically an affine (made of zeros and a few ones to permute the data). When we do repr on the affine we print the matrix and not the axes (which will be handled by the coordinate systems). Here we do the opposite, we print the axes and don't care about the affine.

It's not incorrect, but I see that this goes more towards the philosophy of spatialdata transformations, while the affine stays "true" to the NGFF transformations.

For clarify we could actually always print both: the "index-based/matrix representation" (in this case index 0 goes to 1, index 3 goes to 4 etc; in the affine case the matrix); and then the axes annotation (x -> y, y -> z here; in the case of affine the input and output axes, as we do when we print spatialdata Affine transformations).

)


class TranslationEdge(BaseTransfEdge):

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.

Up to you if doing it here or in a follow-up PR. projectAxis is a crucial transform and we should start using it/play around with it soon.

)

def inverse(self) -> BaseTransfEdge:
inv = np.linalg.inv(self.affine)

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.

worth adding a check on the stability by inspecting the conditioning number. We can reuse the code of decompose affine.

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.

In general inverting matrices is not a good idea, but I think the matrices coming from spatial alignments should be generally fine to be inverted.

raise ValueError("input and output should have the same numbe rof axes")
if not np.isclose(np.linalg.det(linear_matrix), 1.0):
raise ValueError("det(linear_matrix) should be ~= 1")
linear_matrix.flags.writeable = False

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.

what's this?

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.

Even though self.rotation is Final, there is nothing preventing it from being modified internally (potentially through another reference that was kept outside of __init__). This flags raises an exception if anything tries writing to the array.

Still, for extra paranoia we should probably make a copy of linear_matrix so we don't accidentally freeze an array that a user might be meaning to recycle 🤔

Comment on lines +566 to +570
for current_transf in transformations[1:]:
if previous_transf.output != current_transf.input:
raise ValueError(f"Mismatched input/output from {previous_transf} to {current_transf}")
previous_transf = current_transf
self.transformations = transformations

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.

NGFF transformations in a NGFF sequence may not specify the input and output (sometimes). So we should relax this check.

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.

They don't need to specify input and output in their serialized, ome-zarr-models-py form, but in our runtime representation (*Edge) we gurantee that this is always the case (the type of BaseTransfEdge.input is a non-optional CoordSystem). If the inner transformations are being read in from a file, then they will be given virtual coordinate systems for input and output.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants