Skip to content

Add image class - #515

Merged
jo-mueller merged 142 commits into
ome:masterfrom
jo-mueller:introduce-image-class
Jun 11, 2026
Merged

Add image class#515
jo-mueller merged 142 commits into
ome:masterfrom
jo-mueller:introduce-image-class

Conversation

@jo-mueller

@jo-mueller jo-mueller commented Jan 21, 2026

Copy link
Copy Markdown
Collaborator

Hi @will-moore ,

this is an implementation of my idea for a class-based, user-facing API to writing and reading.

Key features

  • Implemented classes: Implements the OMEZarrImage, OMEZarrMultiscale and OMEZarrLabels (similar to the implementation over at ngff-zarr), that serve as primary entrypoints to the writing. OMEZarrImage accepts the data to be written as an array and coerces it to dask internally. It requires the axes (i.e., "tczyx") to be passed at instantiation. It also accepts kwargs for pixel sizes (scale), axes units (axes_units), display settings, channel names and the name of the image (name) which are later serialized in the ome-zarr metadata.
    The OMEZarrMultiscale and OMEZarrLabels then construct a pyramid using the already existing methods (_build_pyramid) that were implemented in deprecate scaler class #516, using different defaults for downsampling (resize and nearest).
  • Ome-zarr-models-py: Usage of ome-zarr-models-py for the internal construction of the corresponding Metadata class from there for simpke serialization and de-serialization in the write/read process. Primarily, the coordinate transformation classes and the Multiscales metadata classes are used.
    Importantly, all metadata is internally coerced to ozmp.v05.Multiscales. Only on writing the metadata class is converted to whatever ome-zarr version is desired.
  • Writing: Writing happens through the OMEZarrMultiscale.to_ome_zarr() method. This method makes use of the already existing writing API from Streamline writing #531 (_write_pyramid_to_zarr). It then converts the metadata to the chosen version and uses pydantic's object.model_dump() to create the metadata dictionary. Importantly, the version conversion is only implemented in implement version converters ome-zarr-models/ome-zarr-models-py#398, so this is currently blocked by that.
  • Reading: The implemented OMEZarrMultiscale/OMEZarrLabels class also has an attached from_ome_zarr(...) classmethod. The argument is simply the path/group of the ome-zarr image. The function then reads the metadata and the multiscales as dask arrays and returns an instance of OMEZarrMultiscale. The version is automatically detected and again coerced to ozmp.v05.Multiscales internally.
  • Labels: Writing labels can be done by converting them to instances of OMEZarrMultiscale and passing them as a single image or as a dict(str, OMEZarrLabels) to the to_ome_zarr writer function. The labels attribute is also automatically populated when an image is read using the .from_ome_zarr function: The reader then searches for all labels that exist under the labels zarr group and adds them as a dict[str, OMEZarrLabels] to the attribute. One can the add more label images to the attribute, call to_ome_zarr(..., overwrite=False), and only the added label images will be written and the corresponding metadata will be updated
  • Backwards compatibility: The existing entrypoints to writing (write_image and write_labels now use the OMEZarrImage,OMEZarrMultiscale and OMEZarrLabels classes under the hood. Since the scale transformations are now entirely calculated under the hood, this means that the coordinateTransformations argument is now essentially deprecated, which I think is good since it was unvalidated anyway. The raised warning message reflects this.
  • Inheritance: The introduce OMEZarrMultiscale and NgffLabels share quite a bit of functionality regarding reading, writing and pyramid generation, so they both derive from a common ancestral class OMEZarrMultiscaleBase, which handles that and offers hook functions for the child classes' respective functionality:
    • _parse_additional_metadata: Called on end of __init__, can be used to parse more stuff (i.e., omero or iamge-labels metadata.
    • _write_additional_meta_data: Called on end of to_ome_zarr() - derived classes can use it to validate and dump more metadata to a store that's only relevant to the respective implementation of OMEZarrMultiscaleBase
    • _read_additional_metadata: Called on end of read_ome_zarr(): Can be used to populate additional (meta)data fields from store, i.e., the .labels field, etc.

All in all, I think especially the to_ome_zarr and from_ome_zarr methods are super convenient. I have written a follow-up implementation of the scene metadata from 0.6 and making use of the same API there makes a lot of sense. We could think of similar entrypoints to writing HCS layouts.

TODOs:

@codecov

codecov Bot commented Jan 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.45742% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.65%. Comparing base (66478bb) to head (d9bad78).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
ome_zarr/classes/image.py 91.98% 29 Missing ⚠️
ome_zarr/utils.py 96.77% 1 Missing ⚠️
ome_zarr/writer.py 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #515      +/-   ##
==========================================
+ Coverage   85.42%   86.65%   +1.23%     
==========================================
  Files          14       16       +2     
  Lines        1921     2316     +395     
==========================================
+ Hits         1641     2007     +366     
- Misses        280      309      +29     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@will-moore

Copy link
Copy Markdown
Member

@jo-mueller Thanks for that - This looks like a nice approach to separate the metadata creation and manipulation from the writing to zarr.

Comparing APIs:

  • This PR:
i = Image(data = my_array, dims = ["c", "z", "y", "x"], scale_factors = [2, 4], scale = [1, 0.5, 0.3, 0.3], axes_units = [None, "micrometer", "micrometer", "micrometer"], name="my image"]
i.to_ome_zarr("my_image.zarr", version="0.4")
  • ngff-zarr
image = nz.to_ngff_image(data, dims=['y', 'x'], scale={'y': 1.0, 'x': 1.0}, translation={'y': 0.0, 'x': 0.0})
multiscales = nz.to_multiscales(image, scale_factors=[2,4], chunks=64)
nz.to_ngff_zarr('lightsheet.ome.zarr', multiscales, chunks_per_shard=2, version="0.4")

Various comments, questions. I realise some of this is just not implemented yet... And I haven't tried the code (which might answer some of these)...

  • Spec(ABC) class isn't used/needed
  • to_zarr() should take an existing root OR string
  • No code for reading ome-zarrs yet
  • The current Scaler doesn't downsample in z in most cases (existing issue). Might be best to calculate the scales for each Dataset from the shape of each new_image
  • axes_units isn't used, nor is labels
  • metadata writing to_zarr is missing currently
  • "version" is ignored. How do we handle versions / converting e.g. read v0.4 and write v0.5?
  • No translation added. See code to add this at 492
  • da.to_zarr() needs to specify dimension_names for zarr v3 arrays, e.g. fixed in fix recursion error if __store.fs.protocol is a tuple #511
  • Where do we specify chunks, shards, compressors and other options to pass down to zarr-python?
  • support for omero metadata? Yaozarrs has _omero but it's not exposed very much
  • I think we could drop support for writing v0.1, v0.2 and v0.3, but what about reading them?
  • Yaozarrs is slightly less verbose than ome-zarr-models-py, but generally similar
  • If your data is a 5D array shape = (50, 3, 100, 1024, 1024) and your dims/scale were 4D, e.g. dims = ["c", "z", "y", "x"] would you get any validation errors?

@jo-mueller

Copy link
Copy Markdown
Collaborator Author

@will-moore thanks for the breakdown. I was aware of some of these points (not all) but decided to send it anyway to not go too far in the wrong direction in case there were strong objections to the approach.

Actually, what you wrote is an excellent to-do list :)

@jo-mueller

Copy link
Copy Markdown
Collaborator Author

@will-moore I think this is taking a bit more shape towards how I'd expect it. But before this can continue, I think there is value in discussing first the current duplicity in functionality between these two functions:

  • write_image: calls _build_pyramid under the hood and uses _write_dask_image to serialize the data and metadata to disk
  • write_multiscales: Requires up-front call to _build_pyramid and then serializes data to disk, uses write_multiscales_metadata to write metadata to disc

There's a lot of overlap between these two functions which I think can be condensed so we'd have a single place where

  • metadata is created
  • format parsing is happening

Anyway, I just tried the Image.to_ome_zarr with a large image (3 x 15k x 15k) and I'm getting very decent writing speeds!

@imagesc-bot

Copy link
Copy Markdown

This pull request has been mentioned on Image.sc Forum. There might be relevant details there:

https://forum.image.sc/t/separate-tiles-to-ome-zarr/109071/55

@will-moore

Copy link
Copy Markdown
Member

@jo-mueller Could you update the description to reflect where this is heading now?

Are we planning to keep all the existing write methods (any API changes)?

@jo-mueller
jo-mueller force-pushed the introduce-image-class branch 2 times, most recently from 2b38492 to c67569d Compare March 5, 2026 09:00
@will-moore

Copy link
Copy Markdown
Member

It would be nice to support writing of "omero" metadata. I think that's covered by ome-zarr-models-py too.
Could be a follow-up PR?

@jo-mueller

jo-mueller commented Mar 6, 2026

Copy link
Copy Markdown
Collaborator Author

@will-moore

Could be a follow-up PR?

Agree.

where this is heading now?
Are we planning to keep all the existing write methods (any API changes)?

THAT is a good question I'm not entirely sure of myself. I guess if we want to go this way further, we would ultimately deprecate write_image and its siblings over the class-based API. As an intermediate step, we could make these functions do something like this under the hood:

def write_image(args, kwargs):
  image = NGffImage(args, kwargs)
  multiscales = NgffMultiscales(image, ....)
  multiscales.to_ome_zarr(....)

which would at least reduce the amount of code to maintain and make sure that everything we do on the class-based API side is covered well by the already existing tests.

What's missing here

The only thing that makes tests fail here currently is this one: ome-zarr-models/ome-zarr-models-py#398. Locally, all tests are passing.

Also, note that this branch has been rebased on #544, so that'll have to go in first, too.

@jo-mueller jo-mueller changed the title WIP: add image class Add image class Mar 6, 2026
@jo-mueller
jo-mueller marked this pull request as ready for review March 6, 2026 16:42
@jo-mueller
jo-mueller force-pushed the introduce-image-class branch from d317e3b to bcdba78 Compare March 10, 2026 10:53
@jo-mueller

Copy link
Copy Markdown
Collaborator Author

@will-moore to go forward with this one, my idea for a soft transition would be this:

Step 1: Refactor - I am just now trying to see if I can get the existing entrypoints (write_image, etc) to convert the passed data to instances of the introduced classes under the hood. For this, I am adding some of the relevant arguments as explicit keywords (i.e., for name, axes_units) which are passed to the NgffImage class where they are internally validated by ozmp.
This doesn't affect ongoing refactorings regarding sharding, because the central writing logic is still in the same place (_write_pyramid_to_zarr), the classes just wrap around this function.
Step 2: Expose: After #548 is merged, this would be a good opportunity to expose the classes more to the outside. Right now, I am refraining a bit from extensive documentation if there are pending changes on the structure of the docs :)

Comment thread ome_zarr/image.py Outdated
Comment thread ome_zarr/classes/image.py
Comment thread ome_zarr/classes/image.py Outdated
@will-moore

will-moore commented Apr 8, 2026

Copy link
Copy Markdown
Member
from ome_zarr import NgffMultiscales
img_path = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/1884807.zarr"
ms = NgffMultiscales.from_ome_zarr(img_path)
out_path = "test_image_class_1884807_05.zarr"
ms.to_ome_zarr(out_path, version="0.5")

This writes an invalid image because the zarr.json has datasets[0].path: "0" but the array is written to path s0.

EDIT: Also the omero metadata is not preserved into the output image

@will-moore

Copy link
Copy Markdown
Member
img_path = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0076A/10501752.zarr"
NgffMultiscales.from_ome_zarr(img_path)
  File "/Users/wmoore/Desktop/ZARR/ome-zarr-py/ome_zarr/classes/image.py", line 547, in from_ome_zarr
    raise ValueError(f"Unsupported OME-Zarr version: {version}")
ValueError: Unsupported OME-Zarr version: 0.4.0

This image has .zattrs with an "unexpected" version:

    "_creator": {
        "name": "omero-zarr",
        "version": "0.4.0"
    },
    "multiscales": [
        { "version": "0.4"...

so the version lookup needs to be a bit more specific

Comment thread docs/source/basic/write_image.ipynb Outdated
jo-mueller and others added 3 commits June 8, 2026 10:03
@jo-mueller

Copy link
Copy Markdown
Collaborator Author

Hi @70Gage70 , thanks for the suggestion. I added a hard removal of any remaining#s in the parser function, so that one could pass the #, if one wanted. Mainly because one can then do this in an IDE (at least in VSCode):

08.06.2026_10.07.34_REC.mp4

Comment thread ome_zarr/classes/image.py Outdated
Comment thread ome_zarr/classes/image.py
jo-mueller and others added 2 commits June 9, 2026 14:19
Co-authored-by: Wouter-Michiel Vierdag <w-mv@hotmail.com>
@jo-mueller

Copy link
Copy Markdown
Collaborator Author

@pennycuda sorry for missing your comment here! We will see to have this merged this week, so to contribute it's probably easiest to check out this branch and open it as a PR to main and rebase later (won't do any force pushes here anymore)

Comment thread ome_zarr/classes/image.py Outdated
Comment thread ome_zarr/classes/image.py

@will-moore will-moore left a comment

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.

LGTM: let's get this in and then followup with any remaining issues we need...

@jo-mueller
jo-mueller merged commit de247e5 into ome:master Jun 11, 2026
14 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in RSE-Unit Jun 11, 2026
@jo-mueller
jo-mueller deleted the introduce-image-class branch June 11, 2026 20:27
timtreis added a commit to scverse/spatialdata that referenced this pull request Jun 20, 2026
ome_zarr 0.18 refactored the functional write_image/write_multiscale
entrypoints (ome/ome-zarr-py#515) to read omero from the top-level
metadata dict; spatialdata passes it nested under metadata["metadata"],
so 0.18 silently dropped it. Effects: write_channel_names() crashed
(omero block absent) and plain write->read lost channel names entirely
(["r","g","b"] came back as [0,1,2]).

Instead of depending on ome-zarr-py to emit omero, write it ourselves:
- _write_raster() now calls overwrite_channel_names() after every image
  write, so the omero block is always present (idempotent on 0.17).
- overwrite_channel_names() defaults to an empty omero block when none
  exists yet.

Pin ome_zarr>=0.18 so CI resolves the same version a fresh install gets
(uv otherwise lands on 0.17, hiding 0.18 regressions). Verified: full
tests/io suite (227) passes on 0.18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
LucaMarconato added a commit to scverse/spatialdata that referenced this pull request Jun 22, 2026
* Migrate supported Python to 3.12, 3.13, 3.14

Drop Python 3.11 (anndata>=0.12 already requires >=3.12, so 3.11 was
effectively broken) and add 3.14.

- pyproject.toml: requires-python ">=3.12", ruff target-version py312
- .mypy.ini: python_version 3.12
- test.yaml: matrix 3.12/3.13/3.14; repoint bleeding-edge deps job to
  3.14 and drop the obsolete requires-python sed hack

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Adopt PEP 695 type aliases for py312 (ruff UP040)

The target-version bump to py312 enables ruff UP040. Rewrite the four
explicit TypeAlias declarations to the `type` keyword and drop the now
unused TypeAlias imports. Annotation-only aliases (the repo uses
`from __future__ import annotations`), so no runtime behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix infinite recursion in dataloader.__getattr__

The module __getattr__ fell through to `getattr(spatialdata.dataloader,
attr_name)` for any unknown name, re-entering itself indefinitely
(RecursionError) instead of raising AttributeError per PEP 562.

This was latent until the docs build hit it: the PEP 695 `type` aliases
live in private modules, so sphinx-autodoc-typehints probes every
`spatialdata.*` submodule with getattr() looking for a public re-export,
tripping the recursion and failing the RTD build.

Raise AttributeError for unknown names; drop the now-unused
`import spatialdata` and tighten the return type to type[ImageTilesDataset].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Pin ome_zarr<0.18.0

ome_zarr 0.18 switched to the NGFF 0.5 layout: channel metadata moved
out of the `omero` block, so overwrite_channel_names() in
_io/_utils.py gets None and crashes (~48 IO test failures). This breaks
main independently of the Python bump. Pin as a stopgap until NGFF 0.5
is supported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Support ome_zarr 0.18 by writing omero channel metadata ourselves

ome_zarr 0.18 refactored the functional write_image/write_multiscale
entrypoints (ome/ome-zarr-py#515) to read omero from the top-level
metadata dict; spatialdata passes it nested under metadata["metadata"],
so 0.18 silently dropped it. Effects: write_channel_names() crashed
(omero block absent) and plain write->read lost channel names entirely
(["r","g","b"] came back as [0,1,2]).

Instead of depending on ome-zarr-py to emit omero, write it ourselves:
- _write_raster() now calls overwrite_channel_names() after every image
  write, so the omero block is always present (idempotent on 0.17).
- overwrite_channel_names() defaults to an empty omero block when none
  exists yet.

Pin ome_zarr>=0.18 so CI resolves the same version a fresh install gets
(uv otherwise lands on 0.17, hiding 0.18 regressions). Verified: full
tests/io suite (227) passes on 0.18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Clean up: drop redundant omero metadata build, lean comments

overwrite_channel_names() now writes the omero channel block on every
image write, so building the same metadata to pass into the ome-zarr-py
writer (which 0.18 ignores anyway) was dead duplication. Remove it along
with the now-unused get_channel_names import, and tighten comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Require integer (or bool) dtype for labels

ome_zarr 0.18's label writer auto-parses unique label values and
validates each `label-value` as an integer (via ome-zarr-models), which
rejected spatialdata's float-dtype labels. Float labels are meaningless
for segmentation masks and inconsistent with the rest of the codebase
(fixtures, rasterize, relabel_sequential all assume integers), so the
correct fix is to enforce it: Labels{2,3}DModel.parse now rejects
non-integer/bool data with a clear error.

Tests that fed float data to label models (reusing image-style
generators) now use integers; test_rasterize_bins_invalid casts a parsed
integer label to float to still exercise rasterize_bins' own guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Relax ome_zarr pin to >=0.16

Our omero self-write and integer-label fixes work across 0.16/0.17/0.18
(verified), so keep the wider lower bound for ecosystem co-installability
rather than forcing >=0.18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* improve labels validation logic

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Luca Marconato <m.lucalmer@gmail.com>
hinderling added a commit to hinderling/napari-ome-zarr that referenced this pull request Jul 4, 2026
….18)

The units tests wrote per-axis units by embedding a "unit" key in the
axes dicts handed to write_image/write_labels. ome-zarr 0.18.0 (released
2026-06-17, PR ome/ome-zarr-py#515 "Add image class") reworked the
writer so it reduces the axes to bare names (_extract_dims_from_axes)
and routes units exclusively through the separate `axes_units` kwarg
(added in ~0.15). An embedded "unit" key is now silently dropped, so the
fixtures wrote axes with no units, the reader had nothing to forward, and
metadata["units"] raised KeyError on every CI job.

CI installs ome-zarr unpinned (test-only dep since ome#123), so the July 1
run picked up the just-released 0.18.0 and went red; nothing in the PR
itself changed. The reader is correct and forwards units unchanged when
they are present on disk.

Supply units via axes_units=SPATIAL_UNITS instead of embedding them, and
drop the now-ignored "unit" keys from the axes dicts. Assertions are
unchanged. Verified: 15/15 reader tests pass on napari 0.7.1 /
ome-zarr 0.18.0 / zarr 3.2.1.
@jo-mueller jo-mueller mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants