You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(ngff): scaffold empty label images in create_empty_plate - #486
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.
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>
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>
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>
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.
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.
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
mainOctober 9, 2026 00:28
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>
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>
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").
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:For every position in
position_keysit 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/scaleminus C. For an existing position they are read from its image, so the label always lines up with the image, including existing pyramids;image-labelmetadata withsource.image = "../../", and the labels list on the labels group;label_dtypeand without the channel axis:_default_chunks(image_shape, label_dtype, version), and for v0.5_default_shards_ratioover the label chunks (one shard per time chunk, spanning ZYX). v0.4 caps the Z chunk to 500 MB atlabel_dtypeand never shards, as for images. Explicit imagechunks/shards_ratioare not inherited: they describe the image. Deriving the default shards from the label chunks also means a caller who passes onlylabel_chunksgets shards that fit them, rather than the image's ratio, which was sized for the image chunks;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_namesentry already exists, and alongsideextra_metadata/metadata_sources.The per-label work goes through a new
Position.create_label_zeros(the label counterpart ofPosition.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-labelsource, zero fill,get_label(name)reads and["0"][...] = arrwrites and reads back.test_create_empty_plate_labels_explicit_layout:label_dtype/label_chunks/label_shards_ratioare used as given.test_create_empty_plate_label_shards_follow_label_chunks: with onlylabel_chunksgiven, 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 imagechunks/shards_ratioare 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'sshape.test_create_empty_plate_labels_refuse_path_backed_transforms: an image with a transform stored atpathgets aValueErrorand no label. Such a transform can't have its channel component removed, and a relative path wouldn't resolve fromlabels/<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 withextra_metadataand 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 underlabels/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: explicitlabel_chunksare 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_ratiowithoutlabel_namesraise instead of being silently ignored.test_create_empty_plate_label_argument_refusals: a non-integer dtype, a non-TZYX chunks/shards argument, orlabel_shards_ratiowith 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 onmain, so this PR does not cause them.Also checked by hand with
ome-zarr-models: a scaffolded position opens asImagewithLabels, and the plate opens asHCS.Checklist
Used by Biohub/biahub#413, which pins this branch.
🤖 Generated with Claude Code