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
86 changes: 85 additions & 1 deletion src/ndevio/bioio_plugins/_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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``
Expand Down
20 changes: 11 additions & 9 deletions src/ndevio/nimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
134 changes: 112 additions & 22 deletions tests/test_bioio_plugins/test_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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'}]}]
Expand All @@ -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')

Expand Down
Loading