Skip to content

NGFF v06 transformations - #1182

Open
Tomaz-Vieira wants to merge 26 commits into
transformation_managerfrom
feature/ngff_v06_transforms
Open

Tomaz-Vieira wants to merge 26 commits 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, parses ome-zarr-models-py classes.

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

Next steps

Roundtripping

This PR only does input dfor now; The full roundtrip is showing some issues that would delay this further, so I've removed all output for now and will submit it in another PR

Using unorderd axes in coordinate systems

Since our arrays are labeled, we probably shouldn't be using a Sequence of axes in our coordinate systems. This will probably simplify runtime quit a lot, but might create some more work during IO

@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 85.18519% with 88 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 87.29% 53 Missing ⚠️
src/spatialdata/transformations/graph/vert.py 77.10% 19 Missing ⚠️
src/spatialdata/_io/io_raster.py 68.62% 16 Missing ⚠️
Additional details and impacted files
@@                    Coverage Diff                    @@
##             transformation_manager    #1182   +/-   ##
=========================================================
  Coverage                          ?   92.22%           
=========================================================
  Files                             ?       55           
  Lines                             ?     8654           
  Branches                          ?        0           
=========================================================
  Hits                              ?     7981           
  Misses                            ?      673           
  Partials                          ?        0           
Files with missing lines Coverage Δ
...ialdata/_core/transformation_manager/exceptions.py 100.00% <100.00%> (ø)
src/spatialdata/transformations/__init__.py 100.00% <100.00%> (ø)
src/spatialdata/_io/io_raster.py 85.44% <68.62%> (ø)
src/spatialdata/transformations/graph/vert.py 77.10% <77.10%> (ø)
src/spatialdata/transformations/graph/edge.py 87.29% <87.29%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread pyproject.toml Outdated
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

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.

RangeIndex is available from xarray 2025.04.0 (pydata/xarray@3816901), so we need to bump our min dependencies in pyproject.toml

Comment thread src/spatialdata/_io/io_raster.py Outdated
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

Comment thread src/spatialdata/_io/io_raster.py
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?

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.

shouldn't we allow for model.unit to be None?

good catch!

Regarding saving unit="unit" in the legacy model, I suppose we could read it in as None

Comment thread src/spatialdata/transformations/graph/vert.py
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 🤔

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe a custom Exception would help here? Also would be good to add some contextual information like the name of the coordinate system and name of the axes to the exception. This would help users to understand/communicate about this error when a user loads an NGFF 0.6 ome-zarr file and gets this exception.

I guess such contextual information would help for all our custom exceptions.

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.

yup, good idea. I was abusing ValueError quite a bit

)

@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.

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.

yeah, this is a wonky attempt to simplify code elsewhere. I'll see if I can clean it up

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, this will currently import Image and Axis that implement NGFF v0.5. Please mention the development branches of dependencies against which this PR is being developed in the PR description.

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's all supposed to be on top of 0.6, but I'll add it to the PR description as well

Comment thread src/spatialdata/transformations/graph/vert.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py Outdated

@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".

Comment thread src/spatialdata/transformations/graph/edge.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py Outdated
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())

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.

I think I had this in the old NGFF classes but never used

It's ripped straight out of there =)

but composing in one go from outside may be more appealing

I'll also consider a .flatten() method

Comment thread src/spatialdata/transformations/graph/edge.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py
Comment thread src/spatialdata/transformations/graph/edge.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py Outdated
Comment thread src/spatialdata/transformations/graph/edge.py Outdated
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would also good to catch LinAlgError and throw a custom exception with more context?

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 🤔

@Tomaz-Vieira
Tomaz-Vieira marked this pull request as ready for review September 11, 2026 10:52
@ajkswamy
ajkswamy force-pushed the transformation_manager branch from b0ff71c to 75e5a44 Compare September 15, 2026 20:18
ajkswamy and others added 21 commits September 15, 2026 22:30
* chore: added pandas-stubs as dev dependency; helps with type checking

* chore: added local hatch.toml to gitignore

* chore: Add .kilo and plans to .gitignore

* feat: added literals and types for element types and group names

* feat: added .coverage and htmlcov to gitignore

* feat: transformation manager at the root of spatialdata object

* fix: io_zarr.py mixup between ELEMENT_TYPE_VECTOR/RASTER

* fix: avoid exposing TransformationManager in spatialdata __init__.py

* fix: element associated to cs -> element belonging to cs

* refac: transformation_manager now uses nx.MultiDiGraph

* refac: tests to match new transformation manager implementation

* fix: restored unintentionally removed functions + others fixes

* feat: custom error messages for transformation graph elements

* feat: custom exceptions for transform manager

* refac: transform manager attributes private; added missing method

* feat: more custom error and warnings + fixes

* feat: more test coverage + fixes

* feat: support for transformation management in graph with mutliple edges

* fix: fixes in tests + renaming for readability

* fix: typing Affine Transform

* feat: TransformationManager return Sequence instead of list

* feat: made TransformationManager.graph type specific over node type

* fix: removed internal attribute access warnings

* refac: rename check_if... methods to assert_... + one additional method

* fix: node type spec for TransformationManager._graph

* fix: made edge key definition from transforms more robust

* refac: method rename

* refac: TransformationManager get/remove methods don't raise error if edges are missing

* feat: new test for TransformationManager.add_transformation

* fix: better error messages and edge case handling for when transformation path is ambiguous

* feat: TransformationManager edge (key) definition made stronger

* fix: simplified access to attributes of TransformationManager

* fix: TransformationManager.unset_element is now private

* fix: throw error a path has one node

* fix: simplified checking if coordinate system has associated transforms

* fix: improved Transformation Manager code quality with better typing

* fix: improved Transformation Manager exception naming and messaging

* fix: improved Transformation Manager documentation

* feat: added tests for Transformation Manager

* fix: used explicit syntax for type definitoin

* refac: typo

* fix: removed TransformationManager import from spatialdata __init__.py

* fix: TransformationManager, simplified code using custom error
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.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@ajkswamy
ajkswamy force-pushed the feature/ngff_v06_transforms branch from 84e3669 to 3a42bef Compare September 15, 2026 20:35
Comment on lines +267 to +276
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")

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 these 2 exception matrix is passed but it is not used.

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.

3 participants