Skip to content

fix(omezarr): self-describing streamed stores, typed fields, no spill - #73

Merged
vboussot merged 3 commits into
mainfrom
feat/omezarr-streamed-field
Jul 29, 2026
Merged

fix(omezarr): self-describing streamed stores, typed fields, no spill#73
vboussot merged 3 commits into
mainfrom
feat/omezarr-streamed-field

Conversation

@vboussot

Copy link
Copy Markdown
Member

Three commits closing the gap between the whole-array and streaming OME-Zarr write paths:

  • ngff-zarr writes the streaming-store metadata — the hand-composed 0.4 multiscales entry meant a DVF came out self-describing when it fit in memory and as an anonymous 3-channel image when streamed (the only path a real one takes). Metadata is described from a one-voxel stand-in (extent-independent for a single level, byte-identical for 0.4 and 0.6; 0.027 s vs 33.4 s for a 13.6 GiB field), the real array is created underneath with the caller's exact region-grid chunking, and three silent failure modes are pinned by tests: version 0.4 explicit (0.5 breaks zarr-python 2), sidecar written after to_ngff_zarr(overwrite=True) (which drops attributes — losing Direction is silent), consolidated index rebuilt last.
  • An output can declare what it writes — the predictor emits arrays, so data_to_file never recognised a field; the declaration now travels in the attributes (the same marker a store reports on read-back), and a generic attributes mapping lets a config stamp its outputs, applied over inheritance so config wins. Asking for a field where RFC-5 is impossible (zarr 2) raises instead of quietly writing an untyped store.
  • No spill of a resident array — ngff-zarr's cache path serialises an in-memory array to disk before writing it, triggered by an estimate inflated by itemsize^(ndim-1) (a 13.6 GiB field reported as 868 GiB); cache=False, since a peak that already happened cannot be lowered.

dask is declared explicitly (imaging/omezarr extras, dev, pixi) — it was imported directly but only reached through ngff-zarr.

Verified: 118 tests green across store creation, displacement-field round-trip and dataset streaming; end to end a region-written field reads back as a DisplacementFieldTransform, axes ('c','displacement'), NGFF 0.6, pixels unchanged.

vboussot added 3 commits July 29, 2026 18:00
create_ome_zarr_store composed its own multiscales entry by hand, frozen at version 0.4 with the
component axis typed "channel". write_ome_zarr had meanwhile moved to ngff-zarr and can type a
displacement field (NGFF RFC-5); the streaming path could not. So the same DVF came out
self-describing when it fit in memory, and as an anonymous 3-channel image when it did not -- and
streaming is precisely the path taken when it does not fit. Hand-written spec metadata drifts, and
this is what the drift cost.

Both paths now take their metadata from ngff-zarr, described from a one-voxel stand-in rather than
from an array of the target shape. With a single resolution level the metadata does not depend on the
extent at all, and the attributes come out byte-identical either way (verified for 0.4 and 0.6).
Handing over the real shape costs a pass over every chunk of an array that is entirely zeros: 33.4 s
for a 13.6 GiB field, to write no bytes. From the stand-in, 0.027 s. The real array is created
underneath that metadata, which is also what makes its chunking exactly the caller's -- the region
grid is the one thing a writer given only a shape cannot infer, and a store whose chunks straddle it
turns every region write into a read-modify-write.

Three failure modes here are silent rather than loud, so each is pinned by a test:

- to_ngff_zarr defaults to version 0.5, which zarr-python 2 cannot write. 0.4 is now explicit; without
  it the Python 3.10 CI leg fails at run time, mid-prediction, with no fallback.
- to_ngff_zarr(overwrite=True) reopens the root in mode="w" and drops every attribute it finds, so the
  KonfAI sidecar is written after it. Written before, Direction disappears, the reader substitutes
  identity, and a plausibly oriented volume comes back.
- the store carries a consolidated index that readers trust over the arrays themselves, and ngff-zarr
  writes it while the array is still the stand-in. It is rebuilt last, after the sidecar.

The level-0 key now comes from the metadata rather than a literal, having moved from "0" to
"scale0/image", and _OmeZarrDataStream._close clears the path-keyed read memo after the rename: a
replaced store can now differ from its predecessor down to that key, so a stale entry would point at
a component that is no longer there.

dask is declared explicitly, being imported directly rather than only reached through ngff-zarr.

1134 unit tests pass, plus 7 new ones covering guarantees previously reached only end to end.
The goal is a registration driven by config alone, ending in an RFC-5 OME-Zarr displacement field.
Two things stood in the way, and both were on the write side.

data_to_file typed a field only when handed a sitk.Transform. The predictor emits arrays, so its
output was never recognised -- and a field too large to hold in memory cannot be wrapped in a
transform at all, since building one means assembling the volume that streaming exists to avoid. The
attributes now carry the declaration instead, which is the same marker a store reports when read
back, so the write and read sides finally speak of it the same way.

create_ome_zarr_store could not type a component axis at all. It can now, sharing the capability
check with write_ome_zarr rather than repeating it: RFC-5 needs a zarr v3 store, and asking for a
field where that is impossible raises instead of quietly writing an untyped 0.4 one. A store that
silently is not a field gets found out much later, by a reader that took its three channels for an
image.

The predictor learns nothing about displacement fields. It gains a generic `attributes` mapping that
an output stamps onto what it writes, applied over what it inherited from its input -- inheritance
carries the geometry, which is right, but also whatever the SOURCE entry said it was, which describes
the source and not this. Declared last so config wins; an empty value drops an inherited key.

Verified end to end: a field written region by region comes back as a DisplacementFieldTransform,
axes ('c', 'displacement'), NGFF 0.6, pixels unchanged.
write_ome_zarr is handed an array that is in memory. to_multiscales then decides for itself whether to
serialise it to ~/.cache/ngff-zarr first, "to limit memory consumption" -- which cannot lower a peak
that has already happened. It buys a full write and a full read, and nothing else.

The estimate that triggers it is inflated on top of that: ngff-zarr's memory_usage multiplies itemsize
once per dimension, so it overstates by itemsize^(ndim-1) -- 64x for a 4-D float32 array. A 13.6 GiB
displacement field is reported as 868 GiB and takes the cache path every time, on exactly the writes
that can least afford it.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 14 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3e289a93-6e08-4cb2-9ea2-ec743ba3c616

📥 Commits

Reviewing files that changed from the base of the PR and between 6d1ddb0 and c504750.

📒 Files selected for processing (6)
  • konfai/predictor.py
  • konfai/utils/dataset.py
  • konfai/utils/ome_zarr.py
  • pyproject.toml
  • tests/unit/test_omezarr_displacement_field.py
  • tests/unit/test_omezarr_store_creation.py

Comment @coderabbitai help to get the list of available commands.

@vboussot

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@vboussot
vboussot merged commit 5f98238 into main Jul 29, 2026
29 of 34 checks passed
@vboussot
vboussot deleted the feat/omezarr-streamed-field branch July 29, 2026 16:59
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.

1 participant