diff --git a/CHANGELOG.md b/CHANGELOG.md index d72a4a20..198020ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog -## [unreleased] +## [v0.5.13] + +### Fix +- Fix `derive_label` (and `OmeZarrContainer.derive_label`) rejecting an explicit `shape` that omits the channel axis. When the reference image has a `c` axis and `channels_policy` removes or overrides it (`"squeeze"`, `"singleton"`, or an integer), the up-front shape-length check failed before the channel policy was applied. The provided shape is now normalized to the reference dimensionality before pyramid computation, so a channel-less shape (e.g. `(z, y, x)` for a `(c, z, y, x)` image) is accepted. `channels_policy="same"` still requires the full shape. ### Chores - Harden GitHub Actions and scan workflows through `zizmor`. diff --git a/src/ngio/images/_abstract_image.py b/src/ngio/images/_abstract_image.py index f3d99ce4..e14a7764 100644 --- a/src/ngio/images/_abstract_image.py +++ b/src/ngio/images/_abstract_image.py @@ -729,6 +729,31 @@ def _compute_pyramid_shapes( return _shapes_from_new_shape(ref_image=ref_image, shape=shape) +def _normalize_shape_for_channel_policy( + ref_image: AbstractImage, + shape: Sequence[int] | None, + channels_policy: Literal["squeeze", "same", "singleton"] | int, +) -> Sequence[int] | None: + """Insert a placeholder channel axis when the provided shape omits it. + + For every channels_policy except "same", the channel axis is fully + determined by the policy ("squeeze" removes it, "singleton"/int overwrite + it), so the caller may pass a shape that omits the channel axis. Pyramid + computation requires a shape matching the reference dimensionality, so we + insert a placeholder channel dimension here; _apply_channel_policy then + removes or overwrites it. See issue #195. + """ + if shape is None or channels_policy == "same": + return shape + channel_index = ref_image.axes_handler.get_index("c") + if channel_index is None: + return shape + if len(shape) == len(ref_image.shape) - 1: + c_size = ref_image.shape[channel_index] + return (*shape[:channel_index], c_size, *shape[channel_index:]) + return shape + + def _check_len_compatibility( ref_shape: tuple[int, ...], chunks: ChunksLike, @@ -1010,6 +1035,11 @@ def abstract_derive( # End of deprecated arguments handling ref_meta = ref_image.meta + shape = _normalize_shape_for_channel_policy( + ref_image=ref_image, + shape=shape, + channels_policy=channels_policy, + ) shapes, scales = _compute_pyramid_shapes( shape=shape, ref_image=ref_image, diff --git a/src/ngio/ome_zarr_meta/v05/_v05_spec.py b/src/ngio/ome_zarr_meta/v05/_v05_spec.py index e85b9900..fe6922a9 100644 --- a/src/ngio/ome_zarr_meta/v05/_v05_spec.py +++ b/src/ngio/ome_zarr_meta/v05/_v05_spec.py @@ -23,7 +23,6 @@ from ome_zarr_models.v05.hcs import HCSAttrs as HCSAttrsV05 from ome_zarr_models.v05.image import ImageAttrs as ImageAttrsV05 from ome_zarr_models.v05.image_label import ImageLabelAttrs as ImageLabelAttrsV05 -from ome_zarr_models.v05.labels import Labels as Labels from ome_zarr_models.v05.labels import LabelsAttrs as LabelsAttrsV05 from ome_zarr_models.v05.multiscales import Dataset as DatasetV05 from ome_zarr_models.v05.multiscales import Multiscale as MultiscaleV05 diff --git a/tests/unit/images/test_create.py b/tests/unit/images/test_create.py index 5c0bdc73..e8ca11d0 100644 --- a/tests/unit/images/test_create.py +++ b/tests/unit/images/test_create.py @@ -161,6 +161,43 @@ def test_derive_label_channels_policy(): assert label.dimensions.get("c") == 2 +def test_derive_label_shape_without_channel_axis(): + # Regression test for https://github.com/BioVisionCenter/ngio/issues/195 + # A (c, z, y, x) image should accept a label shape without the channel axis + # for any policy that owns the channel axis (squeeze/singleton/int). + store = MemoryStore() + ome_zarr = create_synthetic_ome_zarr(store, shape=(1, 1, 64, 64)) + + # Default policy is "squeeze": the channel-less shape is the saved shape. + label = ome_zarr.derive_label("lbl-squeeze", shape=(1, 64, 64)) + assert "c" not in label.axes + assert label.shape == (1, 64, 64) + + label = ome_zarr.derive_label( + "lbl-singleton", shape=(1, 64, 64), channels_policy="singleton" + ) + assert label.dimensions.get("c") == 1 + + label = ome_zarr.derive_label("lbl-int", shape=(1, 64, 64), channels_policy=2) + assert label.dimensions.get("c") == 2 + + # A full-length shape with the channel axis still squeezes (no regression). + label = ome_zarr.derive_label("lbl-squeeze-full", shape=(1, 1, 64, 64)) + assert "c" not in label.axes + assert label.shape == (1, 64, 64) + + # "same" keeps the channel from the shape, so the full shape is required. + with pytest.raises(NgioValueError): + ome_zarr.derive_label("lbl-same", shape=(1, 64, 64), channels_policy="same") + + # A channel-less reference image: the shape is used as-is (nothing to insert), + # even with a non-"same" policy like the default "squeeze". + no_c = create_synthetic_ome_zarr(MemoryStore(), shape=(4, 64, 64)) + label = no_c.derive_label("lbl-no-c", shape=(4, 64, 64)) + assert "c" not in label.axes + assert label.shape == (4, 64, 64) + + def test_derive_from_non_dishogeneus_shapes(): # Yes those shapes are intentionally weird shapes = [