From 25dae3bce8619631f87b93d26f3bf25f0ca64e01 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Fri, 20 Feb 2026 15:14:07 -0600 Subject: [PATCH 1/2] add zarr v03 compat patch and tests --- src/ndevio/bioio_plugins/_compatibility.py | 86 ++++++++++- src/ndevio/nimage.py | 20 +-- .../test_bioio_plugins/test_compatibility.py | 134 +++++++++++++++--- tests/test_nimage.py | 14 +- 4 files changed, 221 insertions(+), 33 deletions(-) diff --git a/src/ndevio/bioio_plugins/_compatibility.py b/src/ndevio/bioio_plugins/_compatibility.py index 2a5e918..fd567bd 100644 --- a/src/ndevio/bioio_plugins/_compatibility.py +++ b/src/ndevio/bioio_plugins/_compatibility.py @@ -4,6 +4,21 @@ consolidated warning when a known incompatibility is detected, so that downstream property accessors can fail silently without repeating noisy messages. + +OME-Zarr spec version differences handled here: + +- **v0.1/v0.2**: No ``axes`` field, no ``coordinateTransformations`` in + ``datasets`` entries. ``bioio_ome_zarr`` falls back to guessing dims + from shape, but ``scale``/``dimension_properties`` raise ``KeyError``. + We warn and let nImage fall back to ``scale=1.0`` / ``units=None``. + +- **v0.3**: ``axes`` is a **list of strings** (e.g. ``["t", "c", "z", "y", "x"]``), + but ``bioio_ome_zarr`` assumes v0.4 dict-axes (``[{"name": "z", ...}]``). + Attempting ``ax["name"]`` on a string raises ``TypeError``. + We normalise string-axes to dict-axes **in-place** on the reader metadata + so all downstream code works transparently. + +- **v0.4+**: ``axes`` is a list of dicts — no patching needed. """ from __future__ import annotations @@ -13,11 +28,80 @@ logger = logging.getLogger(__name__) +# Dimension name → OME-Zarr axis type mapping (v0.4 spec). +_AXIS_TYPE_MAP: dict[str, str] = { + 't': 'time', + 'c': 'channel', + 'z': 'space', + 'y': 'space', + 'x': 'space', +} + if TYPE_CHECKING: from bioio_ome_zarr import Reader as OmeZarrReader -def warn_if_old_zarr_format(reader: OmeZarrReader) -> None: +def apply_ome_zarr_compat_patches(reader: OmeZarrReader) -> None: + """Apply all OME-Zarr compatibility patches to *reader*. + + Currently handles: + - v0.1/v0.2 stores (warning only — no ``coordinateTransformations``) + - v0.3 stores (normalise string-axes to dict-axes in-place) + + Parameters + ---------- + reader : OmeZarrReader + """ + _normalize_v03_string_axes(reader) + _warn_if_no_coordinate_transforms(reader) + + +def _normalize_v03_string_axes(reader: OmeZarrReader) -> None: + """Convert v0.3 string-axes to v0.4 dict-axes in-place. + + OME-Zarr v0.3 stores ``axes`` as ``["t", "c", "z", "y", "x"]``. + ``bioio_ome_zarr`` expects v0.4 format: ``[{"name": "z", "type": "space"}, ...]``. + + This function mutates ``reader._multiscales_metadata`` so that all + downstream code in ``bioio_ome_zarr`` works without modification. + If the axes are already dicts (v0.4+) or absent (v0.1/v0.2), this + is a no-op. + + Parameters + ---------- + reader : OmeZarrReader + """ + multiscales = reader._multiscales_metadata + if not multiscales: + return + + patched = False + for scene_meta in multiscales: + axes = scene_meta.get('axes', []) + if not axes: + continue + # v0.3: axes are strings; v0.4+: axes are dicts + if isinstance(axes[0], str): + scene_meta['axes'] = [ + { + 'name': name, + 'type': _AXIS_TYPE_MAP.get(name.lower(), 'space'), + } + for name in axes + ] + patched = True + + if patched: + version = multiscales[0].get('version', 'unknown (likely 0.3)') + logger.info( + 'OME-Zarr compatibility: normalised v0.3 string-axes to ' + 'v0.4 dict-axes for spec version %s. Image will open ' + 'normally but axis units are unavailable in this format.', + version, + ) + + +def _warn_if_no_coordinate_transforms(reader: OmeZarrReader) -> None: """Emit one warning if *reader* is a ``bioio_ome_zarr.Reader`` for a v0.1/v0.2 store. ``bioio_ome_zarr.Reader`` unconditionally accesses ``coordinateTransformations`` diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index c4d62b6..3a265ca 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -184,9 +184,11 @@ def __init__( # Any compatibility warnings for old formats should be emitted at this point # Cheaply check without imports by looking at the reader's module name if self.reader.__module__.startswith('bioio_ome_zarr'): - from .bioio_plugins._compatibility import warn_if_old_zarr_format + from .bioio_plugins._compatibility import ( + apply_ome_zarr_compat_patches, + ) - warn_if_old_zarr_format(self.reader) + apply_ome_zarr_compat_patches(self.reader) @property def reference_xarray(self) -> xr.DataArray: @@ -370,12 +372,12 @@ def layer_scale(self) -> tuple[float, ...]: axis_labels = self.layer_axis_labels # Try to get scale from BioImage - may fail for array-like inputs - # where physical_pixel_sizes is None (AttributeError), or for old OME-Zarr formats - # (v0.1/v0.2) that lack 'coordinateTransformations' in their dataset - # metadata (introduced in v0.3) (KeyError). + # where physical_pixel_sizes is None (AttributeError), old OME-Zarr + # v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or + # v0.3 string-axes that weren't normalised (TypeError). try: bio_scale = self.scale - except (AttributeError, KeyError): + except (AttributeError, KeyError, TypeError): return tuple(1.0 for _ in axis_labels) return tuple( getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels @@ -427,9 +429,9 @@ def layer_units(self) -> tuple[str | None, ...]: try: dim_props = self.dimension_properties - # Old OME-Zarr v0.1/v0.2: dimension_properties → reader.scale → - # _get_scale_array raises KeyError for missing 'coordinateTransformations'. - except (AttributeError, KeyError): + # Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError), + # or array-like inputs without dimension metadata (AttributeError). + except (AttributeError, KeyError, TypeError): return tuple(None for _ in axis_labels) def _get_unit(dim: str) -> str | None: diff --git a/tests/test_bioio_plugins/test_compatibility.py b/tests/test_bioio_plugins/test_compatibility.py index bdcdb06..15b0bfe 100644 --- a/tests/test_bioio_plugins/test_compatibility.py +++ b/tests/test_bioio_plugins/test_compatibility.py @@ -19,11 +19,12 @@ def _make_v01_multiscales(version: str = '0.1') -> list: return [{'version': version, 'datasets': [{'path': '0'}, {'path': '1'}]}] -def _make_v03_multiscales() -> list: - """Minimal OME-Zarr >=v0.3 multiscales — has coordinateTransformations.""" +def _make_v03_string_axes_multiscales() -> list: + """OME-Zarr v0.3 multiscales — axes are strings, has coordinateTransformations.""" return [ { 'version': '0.3', + 'axes': ['z', 'y', 'x'], 'datasets': [ { 'path': '0', @@ -36,19 +37,43 @@ def _make_v03_multiscales() -> list: ] -class TestWarnIfOldZarrFormat: - """Unit tests for warn_if_old_zarr_format.""" +def _make_v04_multiscales() -> list: + """Minimal OME-Zarr >=v0.4 multiscales — dict-axes and coordinateTransformations.""" + return [ + { + 'version': '0.4', + 'axes': [ + {'name': 'z', 'type': 'space'}, + {'name': 'y', 'type': 'space'}, + {'name': 'x', 'type': 'space'}, + ], + 'datasets': [ + { + 'path': '0', + 'coordinateTransformations': [ + {'type': 'scale', 'scale': [1.0, 0.5, 0.5]} + ], + } + ], + } + ] + + +class TestWarnIfNoCoordinateTransforms: + """Unit tests for _warn_if_no_coordinate_transforms.""" def test_v01_emits_warning(self, caplog): """v0.1 metadata (no coordinateTransformations) triggers a warning.""" - from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) reader = _make_zarr_reader(_make_v01_multiscales('0.1')) with caplog.at_level( logging.WARNING, logger='ndevio.bioio_plugins._compatibility' ): - warn_if_old_zarr_format(reader) + _warn_if_no_coordinate_transforms(reader) assert len(caplog.records) == 1 assert '0.1' in caplog.records[0].message @@ -57,47 +82,70 @@ def test_v01_emits_warning(self, caplog): def test_v02_emits_warning(self, caplog): """v0.2 metadata also triggers a warning.""" - from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) reader = _make_zarr_reader(_make_v01_multiscales('0.2')) with caplog.at_level( logging.WARNING, logger='ndevio.bioio_plugins._compatibility' ): - warn_if_old_zarr_format(reader) + _warn_if_no_coordinate_transforms(reader) assert len(caplog.records) == 1 assert '0.2' in caplog.records[0].message - def test_v03_no_warning(self, caplog): - """v0.3+ metadata (has coordinateTransformations) emits no warning.""" - from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format + def test_v03_with_transforms_no_warning(self, caplog): + """v0.3 metadata (has coordinateTransformations) emits no warning.""" + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) - reader = _make_zarr_reader(_make_v03_multiscales()) + reader = _make_zarr_reader(_make_v03_string_axes_multiscales()) with caplog.at_level( logging.WARNING, logger='ndevio.bioio_plugins._compatibility' ): - warn_if_old_zarr_format(reader) + _warn_if_no_coordinate_transforms(reader) + + assert len(caplog.records) == 0 + + def test_v04_no_warning(self, caplog): + """v0.4 metadata emits no warning.""" + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) + + reader = _make_zarr_reader(_make_v04_multiscales()) + + with caplog.at_level( + logging.WARNING, logger='ndevio.bioio_plugins._compatibility' + ): + _warn_if_no_coordinate_transforms(reader) assert len(caplog.records) == 0 def test_empty_multiscales_no_warning(self, caplog): """Empty multiscales list does not raise and emits no warning.""" - from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) reader = _make_zarr_reader([]) with caplog.at_level( logging.WARNING, logger='ndevio.bioio_plugins._compatibility' ): - warn_if_old_zarr_format(reader) + _warn_if_no_coordinate_transforms(reader) assert len(caplog.records) == 0 def test_unknown_version_in_warning(self, caplog): """When version key is missing the warning still fires with a fallback string.""" - from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format + from ndevio.bioio_plugins._compatibility import ( + _warn_if_no_coordinate_transforms, + ) # No 'version' key, no 'coordinateTransformations' multiscales = [{'datasets': [{'path': '0'}]}] @@ -106,32 +154,74 @@ def test_unknown_version_in_warning(self, caplog): with caplog.at_level( logging.WARNING, logger='ndevio.bioio_plugins._compatibility' ): - warn_if_old_zarr_format(reader) + _warn_if_no_coordinate_transforms(reader) assert len(caplog.records) == 1 assert 'unknown' in caplog.records[0].message.lower() +class TestNormalizeV03StringAxes: + """Unit tests for _normalize_v03_string_axes.""" + + def test_string_axes_normalized(self): + """v0.3 string-axes are converted to v0.4 dict-axes.""" + from ndevio.bioio_plugins._compatibility import ( + _normalize_v03_string_axes, + ) + + reader = _make_zarr_reader(_make_v03_string_axes_multiscales()) + _normalize_v03_string_axes(reader) + + axes = reader._multiscales_metadata[0]['axes'] + assert all(isinstance(ax, dict) for ax in axes) + assert axes[0] == {'name': 'z', 'type': 'space'} + assert axes[1] == {'name': 'y', 'type': 'space'} + assert axes[2] == {'name': 'x', 'type': 'space'} + + def test_dict_axes_untouched(self): + """v0.4 dict-axes are not modified.""" + from ndevio.bioio_plugins._compatibility import ( + _normalize_v03_string_axes, + ) + + reader = _make_zarr_reader(_make_v04_multiscales()) + import copy + + original = copy.deepcopy(reader._multiscales_metadata[0]['axes']) + _normalize_v03_string_axes(reader) + + assert reader._multiscales_metadata[0]['axes'] == original + + def test_empty_multiscales(self): + """No crash on empty multiscales.""" + from ndevio.bioio_plugins._compatibility import ( + _normalize_v03_string_axes, + ) + + reader = _make_zarr_reader([]) + _normalize_v03_string_axes(reader) # should not raise + + class TestNImageCompatibilityGuard: - """Integration tests: nImage.__init__ only calls warn_if_old_zarr_format for zarr readers.""" + """Integration tests: nImage.__init__ calls apply_ome_zarr_compat_patches for zarr readers.""" def test_non_zarr_reader_skips_check(self, resources_dir): - """A TIFF-backed nImage never calls warn_if_old_zarr_format.""" + """A TIFF-backed nImage never calls apply_ome_zarr_compat_patches.""" from ndevio import nImage with patch( - 'ndevio.bioio_plugins._compatibility.warn_if_old_zarr_format' + 'ndevio.bioio_plugins._compatibility.apply_ome_zarr_compat_patches' ) as mock_check: nImage(resources_dir / 'cells3d2ch_legacy.tiff') mock_check.assert_not_called() def test_zarr_reader_calls_check(self, resources_dir): - """A zarr-backed nImage calls warn_if_old_zarr_format exactly once.""" + """A zarr-backed nImage calls apply_ome_zarr_compat_patches exactly once.""" from ndevio import nImage with patch( - 'ndevio.bioio_plugins._compatibility.warn_if_old_zarr_format' + 'ndevio.bioio_plugins._compatibility.apply_ome_zarr_compat_patches' ) as mock_check: nImage(resources_dir / 'dimension_handling_zyx_V3.zarr') diff --git a/tests/test_nimage.py b/tests/test_nimage.py index d9eb2a4..ad45a4f 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -54,7 +54,7 @@ def test_nImage_remote_zarr(): @pytest.mark.network -def test_nImage_remote_zarr_old_format(caplog): +def test_nImage_remote_zarr_v01v02_format(caplog): """Test that nImage emits a warning for old OME-Zarr formats when reading remotely.""" remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.1/9836841.zarr' # from https://github.com/ndev-kit/ndevio/issues/50 with caplog.at_level( @@ -68,6 +68,18 @@ def test_nImage_remote_zarr_old_format(caplog): assert img.layer_units == (None, None) +@pytest.mark.network +def test_nimage_remote_v03_zarr(self): + """Test that nImage can read a real remote OME-Zarr v0.3 store.""" + remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' + img = nImage(remote_zarr) + assert img.path == remote_zarr + assert img._is_remote + assert img.reference_xarray is not None + tuples = img.get_layer_data_tuples() + assert len(tuples) > 0 + + def test_nImage_ome_reader(resources_dir: Path): """ Test that the OME-TIFF reader is used for OME-TIFF files. From 11838921fb48c9107cceed620c7c08a18a52244b Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Fri, 20 Feb 2026 15:25:06 -0600 Subject: [PATCH 2/2] fix v03 nimage test --- tests/test_nimage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_nimage.py b/tests/test_nimage.py index ad45a4f..dd6817e 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -69,7 +69,7 @@ def test_nImage_remote_zarr_v01v02_format(caplog): @pytest.mark.network -def test_nimage_remote_v03_zarr(self): +def test_nimage_remote_v03_zarr(): """Test that nImage can read a real remote OME-Zarr v0.3 store.""" remote_zarr = 'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr' img = nImage(remote_zarr)