Skip to content

feat(ngff): scaffold empty label images in create_empty_plate - #486

Merged
ieivanov merged 12 commits into
mainfrom
feat/create-empty-plate-labels
Oct 16, 2026
Merged

ieivanov merged 12 commits into
mainfrom
feat/create-empty-plate-labels

Conversation

@ieivanov

@ieivanov ieivanov commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Related issue

Supports Biohub/biahub#360 (DCA v0.2 compliance, Biohub/biahub#363).

Builds on #485 (merged): the labels list on the labels group is what this PR's scaffolded labels rely on. The branch has been updated from main.

Summary

Adds label scaffolding to iohub.ngff.utils.create_empty_plate:

label_names: list[str] | None = None
label_dtype: DTypeLike = np.uint32
label_chunks: tuple[int, ...] | None = None        # TZYX
label_shards_ratio: tuple[int, ...] | None = None  # TZYX

For every position in position_keys it creates an empty multiscale label image <fov>/labels/<name>/ that mirrors the image pyramid, one TZYX level per image level with the same path:

  • shape, scale and translation of each level come from the position's image level minus the channel axis. For a new position that is the plate shape/scale minus C. For an existing position they are read from its image, so the label always lines up with the image, including existing pyramids;
  • image-label metadata with source.image = "../../", and the labels list on the labels group;
  • chunks/shards default to the same version defaults the image uses, computed per position and level from that image level's shape (and version), at label_dtype and without the channel axis: _default_chunks(image_shape, label_dtype, version), and for v0.5 _default_shards_ratio over the label chunks (one shard per time chunk, spanning ZYX). v0.4 caps the Z chunk to 500 MB at label_dtype and never shards, as for images. Explicit image chunks/shards_ratio are not inherited: they describe the image. Deriving the default shards from the label chunks also means a caller who passes only label_chunks gets shards that fit them, rather than the image's ratio, which was sized for the image chunks;
  • only metadata is written (fill value 0). Fill a label later with pos.get_label(name)["0"][...] = data.

On an existing plate it only creates labels that are missing. Existing image arrays and existing labels are left untouched, so a second call is a no-op. It works when every channel_names entry already exists, and alongside extra_metadata/metadata_sources.

The per-label work goes through a new Position.create_label_zeros (the label counterpart of Position.create_zeros) and runs inside the per-position thread pool of the batched path from #479. Rough timing for 500 positions (1×2×16×256×256): 2.8 s without labels, 12.9 s with 2 labels per position; a rerun takes 0.3 s without labels and 1.6 s with them.

Testing

New tests in tests/ngff/test_ngff_utils.py:

  • test_create_empty_plate_with_labels[0.4|0.5]: new plate. Checks labels group content, shape/dtype/chunks/shards/axes/scale, image-label source, zero fill, get_label(name) reads and ["0"][...] = arr writes and reads back.
  • test_create_empty_plate_labels_explicit_layout: label_dtype/label_chunks/label_shards_ratio are used as given.
  • test_create_empty_plate_label_shards_follow_label_chunks: with only label_chunks given, the default shard spans the whole ZYX of the label chunks. It fails on the previous revision, which reused the image's ratio.
  • test_create_empty_plate_labels_ignore_explicit_image_layout[0.4|0.5]: explicit image chunks/shards_ratio are not inherited by default labels.
  • test_create_empty_plate_label_defaults_follow_each_position_shape: a position created by an earlier call with another shape gets label chunks and shards sized for its own image, not the call's shape.
  • test_create_empty_plate_labels_refuse_path_backed_transforms: an image with a transform stored at path gets a ValueError and no label. Such a transform can't have its channel component removed, and a relative path wouldn't resolve from labels/<name>; iohub never writes them.
  • test_create_empty_plate_appends_labels_to_existing_plate[0.4|0.5]: existing plate with image data, an existing label and a translated transform. Existing channels are passed again, together with extra_metadata and one new position. Image data and the existing label are preserved, the missing label is added everywhere, and the translation carries over.
  • test_create_empty_plate_labels_second_call_is_noop[0.4|0.5]: no byte under labels/ changes on a rerun.
  • test_create_empty_plate_labels_mirror_image_pyramid[0.4|0.5]: a label added to an image with 3 pyramid levels gets the same 3 levels, with each level's shape, scale and default layout taken from the matching image level.
  • test_create_empty_plate_labels_pyramid_clamps_explicit_chunks: explicit label_chunks are clamped to the shape of each level, and the default shards follow.
  • test_create_empty_plate_label_layout_requires_label_names: label_dtype/label_chunks/label_shards_ratio without label_names raise instead of being silently ignored.
  • test_create_empty_plate_label_argument_refusals: a non-integer dtype, a non-TZYX chunks/shards argument, or label_shards_ratio with version "0.4" fails before anything is written.

uv run pytest tests/ngff -q: 231 passed, 1 skipped, 2 failed. The 2 failures (test_apply_transform_to_{czyx,tczyx}_and_save, a hypothesis example with C-chunk 2 over 3 channels) fail the same way on main, so this PR does not cause them.

Also checked by hand with ome-zarr-models: a scaffolded position opens as Image with Labels, and the plate opens as HCS.

Checklist

  • I added or updated tests, or this change does not require tests.
  • I updated the documentation, or this change does not require documentation.
  • I confirmed this change does not include confidential or sensitive information.

Used by Biohub/biahub#413, which pins this branch.

🤖 Generated with Claude Code

ieivanov and others added 2 commits October 7, 2026 17:48
Position.create_labels_group created `labels/` with empty attributes and
_update_labels_metadata wrote the list of label images into the
position's own metadata (`ome.labels` on the image). OME-NGFF puts that
list on the labels group itself: `labels/zarr.json` as
`{"ome": {"version": "0.5", "labels": [...]}}` for v0.5 and
`labels/.zattrs` as `{"labels": [...]}` for v0.4. Readers that follow the
spec, such as ome-zarr-models, failed to open a position with labels
(KeyError 'ome' from Labels.from_zarr).

Write the list to the labels group instead. ImagesMeta.labels stays in
the model so stores written by older iohub versions still parse, and the
legacy position-level list is dropped the next time labels are updated so
the two locations cannot disagree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Add label_names, label_dtype, label_chunks and label_shards_ratio to
create_empty_plate. For every listed position it creates an empty
multiscale label image at `<fov>/labels/<name>/` with a single level "0"
that matches the position's image minus the channel axis (TZYX shape,
scale and translation), `image-label` metadata pointing at `../../`, and
the labels list on the labels group. Nothing is written but metadata, so
a later step can fill the label chunk by chunk with
`position.get_label(name)["0"][...] = data`.

On an existing plate the call only creates labels that are missing;
existing images and labels are left untouched, so a repeated call is a
no-op. Label chunks and shards default to the image layout without the
channel axis (Z further capped to 500 MB for v0.4).

The per-label work goes through a new Position.create_label_zeros, the
label counterpart of Position.create_zeros, and runs inside the existing
per-position thread pool of the batched creation path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ieivanov and others added 2 commits October 8, 2026 16:17
iohub never reads the labels list (label_names() lists the labels group's
children), and ImagesMeta allows extra keys, so stores written by older
iohub still open without the typed field. Remove ImagesMeta.labels and the
migration that cleared it, keeping only the write to the labels group.

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

# Conflicts:
#	tests/ngff/test_ngff_utils.py
@ieivanov
ieivanov changed the base branch from main to fix/labels-group-metadata October 8, 2026 23:19
Label chunks now come from _default_chunks at label_dtype, and default label
shards from _default_shards_ratio over the label chunks. Before, the labels
reused the image's shards_ratio, which was sized for the image chunks, so a
caller passing only label_chunks got shards that did not fit them. Explicit
image chunks/shards are no longer inherited by default labels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ieivanov
ieivanov marked this pull request as ready for review October 8, 2026 23:52
@ieivanov
ieivanov requested review from srivarra and a balanced review from Copilot October 8, 2026 23:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Labels omit whole-position transforms, causing misalignment with images that use them.

1 open finding
What changed in this PR

Adds empty NGFF label-image scaffolding to plate creation.

Changes:

  • Adds configurable label dtype, chunking, and sharding.
  • Adds Position.create_label_zeros.
  • Tests creation, layouts, existing plates, and idempotency.
File Description
src/​iohub/​ngff/​utils.py Integrates label scaffolding into plate creation.
src/​iohub/​ngff/​nodes.py Adds zero-filled label creation API.
tests/​ngff/​test_ngff_utils.py Covers label scaffolding behavior.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/iohub/ngff/utils.py Resolved
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Layout defaults can use the wrong position shape, and invalid sharding can leave unrecoverable partial label groups.

0 open findings

1 resolved since last review
Previously missed (3)

In code that hasn't changed since last review

Medium severity Validate label chunks and shards before group creation

src/​iohub/​ngff/​nodes.py:2024

Validate chunks/shards_ratio before creating the label group. A direct call with a dimensionality mismatch, or any non-None shard ratio on v0.4, currently creates labels/<name> first and then raises in _create_zarr_array; the dangling group is reported by label_names() and prevents a clean retry. Moving these deterministic checks ahead of create_labels_group() avoids corrupting the position on invalid input.

Medium severity Reject v0.4 label shard ratios before writing

src/​iohub/​ngff/​utils.py:338

Reject an explicit label_shards_ratio for version 0.4 during this preflight. A valid four-element ratio currently passes here; workers then create the image and labels/<name> group before _create_zarr_array rejects sharding for Zarr v2. This leaves a partial store, and a retry skips the empty label group because its name already exists, contrary to the fail-before-write behavior of label argument validation.

Medium severity Compute label defaults from each position's shape

src/​iohub/​ngff/​utils.py:345

These defaults are bound to the call's shape, but _create_missing_labels deliberately derives each existing label's shape from that position's level 0. If an existing position has different dimensions (which this API otherwise supports), its v0.5 chunks are clamped and its default shard ratio is calculated against the wrong volume, so the promised one-shard-per-T ZYX layout is not produced (and the v0.4 chunk cap is likewise based on the wrong shape). Keep only explicit layout arguments shared, and compute missing defaults from each position's image_shape/label_shape inside _create_missing_labels.

🧠 Review effort: Balanced

Base automatically changed from fix/labels-group-metadata to main October 9, 2026 00:28
…te-labels

# Conflicts:
#	tests/ngff/test_ngff_utils.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Default label layouts are incorrectly reused for existing positions whose image shape differs from the call’s shape.

1 open finding

🧠 Review effort: Balanced

Comment thread src/iohub/ngff/utils.py Resolved
Omitted label_chunks/label_shards_ratio were computed once from the call's
shape, but a label added to an existing position takes that position's own
image shape, which can differ (e.g. positions appended by an earlier call
with another shape). Compute the omitted defaults per position, from its
image shape and version; explicit layouts are still used as given.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

OME-Zarr 0.4 sharding input fails only after partially creating the output store.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/iohub/ngff/utils.py Resolved
…-Zarr 0.4 before store creation'

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Path-backed transformations can produce incorrectly aligned label metadata.

1 open finding
1 resolved since last review

🧠 Review effort: Balanced

Comment thread src/iohub/ngff/utils.py Resolved
A coordinate transformation stored at `path` cannot have its channel
component dropped, and a relative path would not resolve from
labels/<name>, so copying it would give the label a transform that does not
describe it. iohub never writes such transforms; raise before creating the
label instead.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Existing-store version handling and transform composition can produce broken or misaligned labels.

1 open finding
1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate label shards against the opened store's actual NGFF version

src/​iohub/​ngff/​utils.py:341

For an existing store, open_ome_zarr infers the actual NGFF version from metadata, but this check uses the caller's version before the store is opened. Calling the documented existing-plate path on a v0.4 plate with the default version="0.5" therefore accepts an explicit label shard ratio; creation then fails only after labels/<name> has been created, and the leftover group is subsequently treated as an existing label and skipped. Validate against each opened position/store's actual version before creating any label group (and do not reject shards for an existing v0.5 store merely because the caller passed version="0.4").

🧠 Review effort: Balanced

Comment thread src/iohub/ngff/utils.py Resolved
…ay label args

A label scaffolded by create_empty_plate now gets one TZYX level per
image level, with the image's dataset paths, shapes and transforms minus
the channel axis. Default chunks and shards are computed per level from
that level's shape; explicit label_chunks are clamped to each level.
Position.create_label_zeros takes the path of the first level.

label_dtype, label_chunks or label_shards_ratio without label_names now
raise instead of being silently ignored, as metadata_keys does without
metadata_sources. The v0.4 shards refusal is documented and tested.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@srivarra srivarra left a comment

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.

Besides the comments left by copilot, I have just one additional comment.

Comment thread src/iohub/ngff/utils.py
Comment on lines +229 to +233
label_shards_ratio : tuple[int, ...], optional
TZYX shards ratio of the label images. If None, for "0.5" the ratio
that makes one shard span a time chunk's whole ZYX volume of each
level of each position's label; no sharding for "0.4". Explicit image
``shards_ratio`` is not inherited. Defaults to None.

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.

question(non-blocking)
Do we still want to support shards_ratio here if #461 is going to deprecate it anyways?

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.

For now let's keep shards_ratio since are are still using it. We can deprecate it once I get around to #461 😆

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.

yee sounds good!

Dataset transforms apply before multiscale-level ones, so flattening
both into the label's dataset transforms reordered non-commuting
operations such as a dataset scale and a FOV translation. The label
now gets the channel-stripped dataset transforms on each level and the
channel-stripped multiscale-level transforms on its own multiscale.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ieivanov
ieivanov merged commit c9832a7 into main Oct 16, 2026
19 checks passed
@ieivanov
ieivanov deleted the feat/create-empty-plate-labels branch October 16, 2026 21:42
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