Skip to content

Commit f4ffc72

Browse files
authored
Add compatability patch for OME-Zarr v0.3 (#59)
# Description While trying out the sample image `'https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.3/9836842.zarr/'` from https://github.com/ome/napari-ome-zarr README, I learned that v0.3 OME-Zarr format is also not supported. This uses copilot with Opus 4.6 to generate a patch for compatability by converting the strings to v0.4 metadata dict. These changes looked ok enough and are inconsequential enough that I'm ok merged, especially since there is a `TypeErorr` fallback to quietly have a basic scale and units.
1 parent 79375ce commit f4ffc72

File tree

4 files changed

+221
-33
lines changed

4 files changed

+221
-33
lines changed

src/ndevio/bioio_plugins/_compatibility.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44
consolidated warning when a known incompatibility is detected, so that
55
downstream property accessors can fail silently without repeating noisy
66
messages.
7+
8+
OME-Zarr spec version differences handled here:
9+
10+
- **v0.1/v0.2**: No ``axes`` field, no ``coordinateTransformations`` in
11+
``datasets`` entries. ``bioio_ome_zarr`` falls back to guessing dims
12+
from shape, but ``scale``/``dimension_properties`` raise ``KeyError``.
13+
We warn and let nImage fall back to ``scale=1.0`` / ``units=None``.
14+
15+
- **v0.3**: ``axes`` is a **list of strings** (e.g. ``["t", "c", "z", "y", "x"]``),
16+
but ``bioio_ome_zarr`` assumes v0.4 dict-axes (``[{"name": "z", ...}]``).
17+
Attempting ``ax["name"]`` on a string raises ``TypeError``.
18+
We normalise string-axes to dict-axes **in-place** on the reader metadata
19+
so all downstream code works transparently.
20+
21+
- **v0.4+**: ``axes`` is a list of dicts — no patching needed.
722
"""
823

924
from __future__ import annotations
@@ -13,11 +28,80 @@
1328

1429
logger = logging.getLogger(__name__)
1530

31+
# Dimension name → OME-Zarr axis type mapping (v0.4 spec).
32+
_AXIS_TYPE_MAP: dict[str, str] = {
33+
't': 'time',
34+
'c': 'channel',
35+
'z': 'space',
36+
'y': 'space',
37+
'x': 'space',
38+
}
39+
1640
if TYPE_CHECKING:
1741
from bioio_ome_zarr import Reader as OmeZarrReader
1842

1943

20-
def warn_if_old_zarr_format(reader: OmeZarrReader) -> None:
44+
def apply_ome_zarr_compat_patches(reader: OmeZarrReader) -> None:
45+
"""Apply all OME-Zarr compatibility patches to *reader*.
46+
47+
Currently handles:
48+
- v0.1/v0.2 stores (warning only — no ``coordinateTransformations``)
49+
- v0.3 stores (normalise string-axes to dict-axes in-place)
50+
51+
Parameters
52+
----------
53+
reader : OmeZarrReader
54+
"""
55+
_normalize_v03_string_axes(reader)
56+
_warn_if_no_coordinate_transforms(reader)
57+
58+
59+
def _normalize_v03_string_axes(reader: OmeZarrReader) -> None:
60+
"""Convert v0.3 string-axes to v0.4 dict-axes in-place.
61+
62+
OME-Zarr v0.3 stores ``axes`` as ``["t", "c", "z", "y", "x"]``.
63+
``bioio_ome_zarr`` expects v0.4 format: ``[{"name": "z", "type": "space"}, ...]``.
64+
65+
This function mutates ``reader._multiscales_metadata`` so that all
66+
downstream code in ``bioio_ome_zarr`` works without modification.
67+
If the axes are already dicts (v0.4+) or absent (v0.1/v0.2), this
68+
is a no-op.
69+
70+
Parameters
71+
----------
72+
reader : OmeZarrReader
73+
"""
74+
multiscales = reader._multiscales_metadata
75+
if not multiscales:
76+
return
77+
78+
patched = False
79+
for scene_meta in multiscales:
80+
axes = scene_meta.get('axes', [])
81+
if not axes:
82+
continue
83+
# v0.3: axes are strings; v0.4+: axes are dicts
84+
if isinstance(axes[0], str):
85+
scene_meta['axes'] = [
86+
{
87+
'name': name,
88+
'type': _AXIS_TYPE_MAP.get(name.lower(), 'space'),
89+
}
90+
for name in axes
91+
]
92+
patched = True
93+
94+
if patched:
95+
version = multiscales[0].get('version', 'unknown (likely 0.3)')
96+
logger.info(
97+
'OME-Zarr compatibility: normalised v0.3 string-axes to '
98+
'v0.4 dict-axes for spec version %s. Image will open '
99+
'normally but axis units are unavailable in this format.',
100+
version,
101+
)
102+
103+
104+
def _warn_if_no_coordinate_transforms(reader: OmeZarrReader) -> None:
21105
"""Emit one warning if *reader* is a ``bioio_ome_zarr.Reader`` for a v0.1/v0.2 store.
22106
23107
``bioio_ome_zarr.Reader`` unconditionally accesses ``coordinateTransformations``

src/ndevio/nimage.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,11 @@ def __init__(
184184
# Any compatibility warnings for old formats should be emitted at this point
185185
# Cheaply check without imports by looking at the reader's module name
186186
if self.reader.__module__.startswith('bioio_ome_zarr'):
187-
from .bioio_plugins._compatibility import warn_if_old_zarr_format
187+
from .bioio_plugins._compatibility import (
188+
apply_ome_zarr_compat_patches,
189+
)
188190

189-
warn_if_old_zarr_format(self.reader)
191+
apply_ome_zarr_compat_patches(self.reader)
190192

191193
@property
192194
def reference_xarray(self) -> xr.DataArray:
@@ -370,12 +372,12 @@ def layer_scale(self) -> tuple[float, ...]:
370372
axis_labels = self.layer_axis_labels
371373

372374
# Try to get scale from BioImage - may fail for array-like inputs
373-
# where physical_pixel_sizes is None (AttributeError), or for old OME-Zarr formats
374-
# (v0.1/v0.2) that lack 'coordinateTransformations' in their dataset
375-
# metadata (introduced in v0.3) (KeyError).
375+
# where physical_pixel_sizes is None (AttributeError), old OME-Zarr
376+
# v0.1/v0.2 missing 'coordinateTransformations' (KeyError), or
377+
# v0.3 string-axes that weren't normalised (TypeError).
376378
try:
377379
bio_scale = self.scale
378-
except (AttributeError, KeyError):
380+
except (AttributeError, KeyError, TypeError):
379381
return tuple(1.0 for _ in axis_labels)
380382
return tuple(
381383
getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels
@@ -427,9 +429,9 @@ def layer_units(self) -> tuple[str | None, ...]:
427429

428430
try:
429431
dim_props = self.dimension_properties
430-
# Old OME-Zarr v0.1/v0.2: dimension_properties → reader.scale →
431-
# _get_scale_array raises KeyError for missing 'coordinateTransformations'.
432-
except (AttributeError, KeyError):
432+
# Old OME-Zarr v0.1/v0.2 (KeyError), v0.3 string-axes (TypeError),
433+
# or array-like inputs without dimension metadata (AttributeError).
434+
except (AttributeError, KeyError, TypeError):
433435
return tuple(None for _ in axis_labels)
434436

435437
def _get_unit(dim: str) -> str | None:

tests/test_bioio_plugins/test_compatibility.py

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ def _make_v01_multiscales(version: str = '0.1') -> list:
1919
return [{'version': version, 'datasets': [{'path': '0'}, {'path': '1'}]}]
2020

2121

22-
def _make_v03_multiscales() -> list:
23-
"""Minimal OME-Zarr >=v0.3 multiscales — has coordinateTransformations."""
22+
def _make_v03_string_axes_multiscales() -> list:
23+
"""OME-Zarr v0.3 multiscales — axes are strings, has coordinateTransformations."""
2424
return [
2525
{
2626
'version': '0.3',
27+
'axes': ['z', 'y', 'x'],
2728
'datasets': [
2829
{
2930
'path': '0',
@@ -36,19 +37,43 @@ def _make_v03_multiscales() -> list:
3637
]
3738

3839

39-
class TestWarnIfOldZarrFormat:
40-
"""Unit tests for warn_if_old_zarr_format."""
40+
def _make_v04_multiscales() -> list:
41+
"""Minimal OME-Zarr >=v0.4 multiscales — dict-axes and coordinateTransformations."""
42+
return [
43+
{
44+
'version': '0.4',
45+
'axes': [
46+
{'name': 'z', 'type': 'space'},
47+
{'name': 'y', 'type': 'space'},
48+
{'name': 'x', 'type': 'space'},
49+
],
50+
'datasets': [
51+
{
52+
'path': '0',
53+
'coordinateTransformations': [
54+
{'type': 'scale', 'scale': [1.0, 0.5, 0.5]}
55+
],
56+
}
57+
],
58+
}
59+
]
60+
61+
62+
class TestWarnIfNoCoordinateTransforms:
63+
"""Unit tests for _warn_if_no_coordinate_transforms."""
4164

4265
def test_v01_emits_warning(self, caplog):
4366
"""v0.1 metadata (no coordinateTransformations) triggers a warning."""
44-
from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format
67+
from ndevio.bioio_plugins._compatibility import (
68+
_warn_if_no_coordinate_transforms,
69+
)
4570

4671
reader = _make_zarr_reader(_make_v01_multiscales('0.1'))
4772

4873
with caplog.at_level(
4974
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
5075
):
51-
warn_if_old_zarr_format(reader)
76+
_warn_if_no_coordinate_transforms(reader)
5277

5378
assert len(caplog.records) == 1
5479
assert '0.1' in caplog.records[0].message
@@ -57,47 +82,70 @@ def test_v01_emits_warning(self, caplog):
5782

5883
def test_v02_emits_warning(self, caplog):
5984
"""v0.2 metadata also triggers a warning."""
60-
from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format
85+
from ndevio.bioio_plugins._compatibility import (
86+
_warn_if_no_coordinate_transforms,
87+
)
6188

6289
reader = _make_zarr_reader(_make_v01_multiscales('0.2'))
6390

6491
with caplog.at_level(
6592
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
6693
):
67-
warn_if_old_zarr_format(reader)
94+
_warn_if_no_coordinate_transforms(reader)
6895

6996
assert len(caplog.records) == 1
7097
assert '0.2' in caplog.records[0].message
7198

72-
def test_v03_no_warning(self, caplog):
73-
"""v0.3+ metadata (has coordinateTransformations) emits no warning."""
74-
from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format
99+
def test_v03_with_transforms_no_warning(self, caplog):
100+
"""v0.3 metadata (has coordinateTransformations) emits no warning."""
101+
from ndevio.bioio_plugins._compatibility import (
102+
_warn_if_no_coordinate_transforms,
103+
)
75104

76-
reader = _make_zarr_reader(_make_v03_multiscales())
105+
reader = _make_zarr_reader(_make_v03_string_axes_multiscales())
77106

78107
with caplog.at_level(
79108
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
80109
):
81-
warn_if_old_zarr_format(reader)
110+
_warn_if_no_coordinate_transforms(reader)
111+
112+
assert len(caplog.records) == 0
113+
114+
def test_v04_no_warning(self, caplog):
115+
"""v0.4 metadata emits no warning."""
116+
from ndevio.bioio_plugins._compatibility import (
117+
_warn_if_no_coordinate_transforms,
118+
)
119+
120+
reader = _make_zarr_reader(_make_v04_multiscales())
121+
122+
with caplog.at_level(
123+
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
124+
):
125+
_warn_if_no_coordinate_transforms(reader)
82126

83127
assert len(caplog.records) == 0
84128

85129
def test_empty_multiscales_no_warning(self, caplog):
86130
"""Empty multiscales list does not raise and emits no warning."""
87-
from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format
131+
from ndevio.bioio_plugins._compatibility import (
132+
_warn_if_no_coordinate_transforms,
133+
)
88134

89135
reader = _make_zarr_reader([])
90136

91137
with caplog.at_level(
92138
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
93139
):
94-
warn_if_old_zarr_format(reader)
140+
_warn_if_no_coordinate_transforms(reader)
95141

96142
assert len(caplog.records) == 0
97143

98144
def test_unknown_version_in_warning(self, caplog):
99145
"""When version key is missing the warning still fires with a fallback string."""
100-
from ndevio.bioio_plugins._compatibility import warn_if_old_zarr_format
146+
from ndevio.bioio_plugins._compatibility import (
147+
_warn_if_no_coordinate_transforms,
148+
)
101149

102150
# No 'version' key, no 'coordinateTransformations'
103151
multiscales = [{'datasets': [{'path': '0'}]}]
@@ -106,32 +154,74 @@ def test_unknown_version_in_warning(self, caplog):
106154
with caplog.at_level(
107155
logging.WARNING, logger='ndevio.bioio_plugins._compatibility'
108156
):
109-
warn_if_old_zarr_format(reader)
157+
_warn_if_no_coordinate_transforms(reader)
110158

111159
assert len(caplog.records) == 1
112160
assert 'unknown' in caplog.records[0].message.lower()
113161

114162

163+
class TestNormalizeV03StringAxes:
164+
"""Unit tests for _normalize_v03_string_axes."""
165+
166+
def test_string_axes_normalized(self):
167+
"""v0.3 string-axes are converted to v0.4 dict-axes."""
168+
from ndevio.bioio_plugins._compatibility import (
169+
_normalize_v03_string_axes,
170+
)
171+
172+
reader = _make_zarr_reader(_make_v03_string_axes_multiscales())
173+
_normalize_v03_string_axes(reader)
174+
175+
axes = reader._multiscales_metadata[0]['axes']
176+
assert all(isinstance(ax, dict) for ax in axes)
177+
assert axes[0] == {'name': 'z', 'type': 'space'}
178+
assert axes[1] == {'name': 'y', 'type': 'space'}
179+
assert axes[2] == {'name': 'x', 'type': 'space'}
180+
181+
def test_dict_axes_untouched(self):
182+
"""v0.4 dict-axes are not modified."""
183+
from ndevio.bioio_plugins._compatibility import (
184+
_normalize_v03_string_axes,
185+
)
186+
187+
reader = _make_zarr_reader(_make_v04_multiscales())
188+
import copy
189+
190+
original = copy.deepcopy(reader._multiscales_metadata[0]['axes'])
191+
_normalize_v03_string_axes(reader)
192+
193+
assert reader._multiscales_metadata[0]['axes'] == original
194+
195+
def test_empty_multiscales(self):
196+
"""No crash on empty multiscales."""
197+
from ndevio.bioio_plugins._compatibility import (
198+
_normalize_v03_string_axes,
199+
)
200+
201+
reader = _make_zarr_reader([])
202+
_normalize_v03_string_axes(reader) # should not raise
203+
204+
115205
class TestNImageCompatibilityGuard:
116-
"""Integration tests: nImage.__init__ only calls warn_if_old_zarr_format for zarr readers."""
206+
"""Integration tests: nImage.__init__ calls apply_ome_zarr_compat_patches for zarr readers."""
117207

118208
def test_non_zarr_reader_skips_check(self, resources_dir):
119-
"""A TIFF-backed nImage never calls warn_if_old_zarr_format."""
209+
"""A TIFF-backed nImage never calls apply_ome_zarr_compat_patches."""
120210
from ndevio import nImage
121211

122212
with patch(
123-
'ndevio.bioio_plugins._compatibility.warn_if_old_zarr_format'
213+
'ndevio.bioio_plugins._compatibility.apply_ome_zarr_compat_patches'
124214
) as mock_check:
125215
nImage(resources_dir / 'cells3d2ch_legacy.tiff')
126216

127217
mock_check.assert_not_called()
128218

129219
def test_zarr_reader_calls_check(self, resources_dir):
130-
"""A zarr-backed nImage calls warn_if_old_zarr_format exactly once."""
220+
"""A zarr-backed nImage calls apply_ome_zarr_compat_patches exactly once."""
131221
from ndevio import nImage
132222

133223
with patch(
134-
'ndevio.bioio_plugins._compatibility.warn_if_old_zarr_format'
224+
'ndevio.bioio_plugins._compatibility.apply_ome_zarr_compat_patches'
135225
) as mock_check:
136226
nImage(resources_dir / 'dimension_handling_zyx_V3.zarr')
137227

0 commit comments

Comments
 (0)