Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
30 changes: 30 additions & 0 deletions src/ngio/images/_abstract_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion src/ngio/ome_zarr_meta/v05/_v05_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/images/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading