From f5dbee871c61503b491a483e3757c6215a4e2775 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 5 Aug 2026 16:01:46 -0500 Subject: [PATCH 1/2] create a layer metadata manager --- src/ndevio/nimage.py | 83 ++++------- src/ndevio/utils/_layer_metadata.py | 133 +++++++++++++++++ tests/test_utils/test_layer_metadata.py | 189 ++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 58 deletions(-) create mode 100644 src/ndevio/utils/_layer_metadata.py create mode 100644 tests/test_utils/test_layer_metadata.py diff --git a/src/ndevio/nimage.py b/src/ndevio/nimage.py index ca57037..944d542 100644 --- a/src/ndevio/nimage.py +++ b/src/ndevio/nimage.py @@ -10,6 +10,7 @@ from bioio import BioImage from .bioio_plugins._manager import raise_unsupported_with_suggestions +from .utils._layer_metadata import LayerMetadata, build_layer_metadata from .utils._layer_utils import ( build_layer_tuple, resolve_layer_type, @@ -74,6 +75,7 @@ class nImage(BioImage): _is_remote: bool _reference_xarray: xr.DataArray | None _layer_data: list | None + _layer_metadata: LayerMetadata | None _use_dask_cache: bool | None def __init__( @@ -109,6 +111,7 @@ def __init__( # Instance state self._reference_xarray = None self._layer_data = None + self._layer_metadata = None self._use_dask_cache = None self._initialize_source_state(image) @@ -332,6 +335,19 @@ def layer_names(self) -> list[str]: # Multichannel return [f'{ch} :: {base_name}' for ch in channel_names] + @property + def _resolved_metadata(self) -> LayerMetadata: + """LayerMetadata for the current scene, built lazily and cached. + + Mirrors the ``reference_xarray`` / ``layer_data`` cache idiom: + computed once on first access and invalidated by :meth:`set_scene`. + """ + if self._layer_metadata is None: + self._layer_metadata = build_layer_metadata( + self, self.reference_xarray.dims + ) + return self._layer_metadata + @property def layer_scale(self) -> tuple[float, ...]: """Physical scale for dimensions in layer data. @@ -354,19 +370,7 @@ def layer_scale(self) -> tuple[float, ...]: (2.0, 0.2, 0.2) """ - 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), 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, TypeError): - return tuple(1.0 for _ in axis_labels) - return tuple( - getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels - ) + return self._resolved_metadata.scale @property def layer_axis_labels(self) -> tuple[str, ...]: @@ -384,12 +388,7 @@ def layer_axis_labels(self) -> tuple[str, ...]: ('Z', 'Y', 'X') """ - layer_data = self.reference_xarray - - # Exclude Channel and Samples dimensions (RGB/multichannel handled separately) - return tuple( - str(dim) for dim in layer_data.dims if dim not in ('C', 'S') - ) + return self._resolved_metadata.axis_labels @property def layer_units(self) -> tuple[str | None, ...]: @@ -410,20 +409,7 @@ def layer_units(self) -> tuple[str | None, ...]: ('s', 'µm', 'µm') """ - axis_labels = self.layer_axis_labels - - try: - dim_props = self.dimension_properties - # 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: - prop = getattr(dim_props, dim, None) - return prop.unit if prop else None - - return tuple(_get_unit(dim) for dim in axis_labels) + return self._resolved_metadata.units @property def layer_metadata(self) -> dict: @@ -437,27 +423,7 @@ def layer_metadata(self) -> dict: Keys: 'bioimage', 'raw_image_metadata', and optionally 'ome_metadata'. """ - meta: dict = { - 'bioimage': self, - 'raw_image_metadata': self.metadata, - } - - try: - meta['ome_metadata'] = self.ome_metadata - except NotImplementedError: - pass # Reader doesn't support OME metadata - except (ValueError, TypeError, KeyError) as e: - # Some files have metadata that doesn't conform to OME schema, despite bioio attempting to parse it - # (e.g., CZI files with LatticeLightsheet acquisition mode) - # As such, when accessing ome_metadata, we may get various exceptions - # Log warning but continue - raw metadata is still available - logger.warning( - 'Could not parse OME metadata: %s. ' - "Raw metadata is still available in 'raw_image_metadata'.", - e, - ) - - return meta + return self._resolved_metadata.metadata def get_layer_data_tuples( self, @@ -518,10 +484,11 @@ def get_layer_data_tuples( if layer_type is not None: channel_types = None # Global override ignores per-channel names = self.layer_names - base_metadata = self.layer_metadata - scale = self.layer_scale - axis_labels = self.layer_axis_labels - units = self.layer_units + layer_meta = self._resolved_metadata + base_metadata = layer_meta.metadata + scale = layer_meta.scale + axis_labels = layer_meta.axis_labels + units = layer_meta.units # Handle RGB images (Samples dimension 'S') if 'S' in self.dims.order: diff --git a/src/ndevio/utils/_layer_metadata.py b/src/ndevio/utils/_layer_metadata.py new file mode 100644 index 0000000..4fa4adf --- /dev/null +++ b/src/ndevio/utils/_layer_metadata.py @@ -0,0 +1,133 @@ +"""Translate physical metadata from a BioImage for napari layers. + +This module owns the OME-Zarr spec-version quirks and fallback behaviour that +previously lived scattered across ``nImage``'s ``layer_*`` properties. Its +interface is deliberately small: given a ``BioImage`` (or any object exposing +the same ``scale``, ``dimension_properties``, ``metadata`` and ``ome_metadata`` +accessors) plus the squeezed dimension names, it returns a fully-resolved +:class:`LayerMetadata`. + +Tests pass a lightweight stand-in for ``BioImage`` — no real files or BioImage +instances required. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from bioio import BioImage + +logger = logging.getLogger(__name__) + +# Dimensions that are not exposed as napari layer axes (handled separately via +# channel splitting / RGB handling). +_EXCLUDED_DIMS = frozenset({'C', 'S'}) + +# Exceptions raised by BioImage readers when physical metadata is missing or +# stored in an old OME-Zarr format: +# - array-like inputs lack physical_pixel_sizes -> AttributeError +# - OME-Zarr v0.1/v0.2 lack 'coordinateTransformations' -> KeyError +# - v0.3 string-axes aren't normalised -> TypeError +_METADATA_UNAVAILABLE = (AttributeError, KeyError, TypeError) + + +@dataclass(frozen=True) +class LayerMetadata: + """Resolved physical metadata for a napari layer. + + The tuples are aligned 1:1 with the (squeezed) layer data dimensions — + napari requires ``axis_labels`` and ``units`` to have exactly ``ndim`` + entries. + """ + + axis_labels: tuple[str, ...] + scale: tuple[float, ...] + units: tuple[str | None, ...] + metadata: dict + + +def build_layer_metadata( + image: BioImage, dims: Sequence[str] +) -> LayerMetadata: + """Resolve napari layer metadata from a BioImage. + + Parameters + ---------- + image : BioImage + The image to read physical metadata from (e.g. an ``nImage``). + dims : Sequence[str] + The squeezed dimension names of the layer data (e.g. from the + reference xarray). Channel and Samples dims are excluded here. + + Returns + ------- + LayerMetadata + Axis labels, scale, units, and the layer metadata dict. Physical + metadata that cannot be read falls back to ``scale=1.0`` / ``units=None`` + rather than raising. + """ + axis_labels = tuple(str(d) for d in dims if d not in _EXCLUDED_DIMS) + return LayerMetadata( + axis_labels=axis_labels, + scale=_resolve_scale(image, axis_labels), + units=_resolve_units(image, axis_labels), + metadata=_resolve_metadata(image), + ) + + +def _resolve_scale( + image: BioImage, axis_labels: tuple[str, ...] +) -> tuple[float, ...]: + """Read per-axis scale from *image*, defaulting each axis to 1.0.""" + try: + bio_scale = image.scale + except _METADATA_UNAVAILABLE: + return tuple(1.0 for _ in axis_labels) + return tuple(getattr(bio_scale, dim, None) or 1.0 for dim in axis_labels) + + +def _resolve_units( + image: BioImage, axis_labels: tuple[str, ...] +) -> tuple[str | None, ...]: + """Read per-axis units from *image*, defaulting each axis to None.""" + try: + dim_props = image.dimension_properties + except _METADATA_UNAVAILABLE: + return tuple(None for _ in axis_labels) + + def _get_unit(dim: str) -> str | None: + prop = getattr(dim_props, dim, None) + return prop.unit if prop else None + + return tuple(_get_unit(dim) for dim in axis_labels) + + +def _resolve_metadata(image: BioImage) -> dict: + """Build the layer metadata dict, tolerating unparseable OME metadata.""" + meta: dict = { + 'bioimage': image, + 'raw_image_metadata': image.metadata, + } + + try: + meta['ome_metadata'] = image.ome_metadata + except NotImplementedError: + pass # Reader doesn't support OME metadata + except (ValueError, TypeError, KeyError) as e: + # Some files have metadata that doesn't conform to OME schema, despite + # bioio attempting to parse it (e.g. CZI files with LatticeLightsheet + # acquisition mode). Log a warning but keep the raw metadata. + logger.warning( + 'Could not parse OME metadata: %s. ' + "Raw metadata is still available in 'raw_image_metadata'.", + e, + ) + return meta + + +__all__ = ['LayerMetadata', 'build_layer_metadata'] diff --git a/tests/test_utils/test_layer_metadata.py b/tests/test_utils/test_layer_metadata.py new file mode 100644 index 0000000..031f99e --- /dev/null +++ b/tests/test_utils/test_layer_metadata.py @@ -0,0 +1,189 @@ +"""Tests for the ndevio layer metadata translator. + +The translator is exercised through a lightweight stand-in for ``BioImage``, +so the whole OME-Zarr / array-input quirk matrix is covered without real +files. +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest + +from ndevio.utils._layer_metadata import LayerMetadata, build_layer_metadata + +# Squeezed dims may still include C (multichannel) and S (RGB); the translator +# must exclude both from the exposed axis labels. +DIMS = ('T', 'C', 'Z', 'Y', 'X') +DIMS_WITH_SAMPLES = ('T', 'C', 'Z', 'Y', 'X', 'S') + + +class FakeBioImage: + """Lightweight stand-in for BioImage's metadata accessors. + + ``scale``, ``dimension_properties`` and ``ome_metadata`` behave like the + real BioImage properties: passing an exception *class* makes the property + raise it, mirroring the OME-Zarr / array-input quirks. + """ + + def __init__( + self, + *, + scale, + dimension_properties, + metadata=None, + ome_metadata=None, + ): + self._scale = scale + self._dimension_properties = dimension_properties + self._metadata = metadata if metadata is not None else {'raw': True} + self._ome_metadata = ome_metadata + + @property + def scale(self): + return self._raise_if_needed(self._scale) + + @property + def dimension_properties(self): + return self._raise_if_needed(self._dimension_properties) + + @property + def metadata(self): + return self._metadata + + @property + def ome_metadata(self): + return self._raise_if_needed(self._ome_metadata) + + @staticmethod + def _raise_if_needed(value): + if isinstance(value, type) and issubclass(value, Exception): + raise value() + return value + + +def make_fake_image( + *, + scale=None, + dimension_properties=None, + metadata=None, + ome_metadata=None, +): + """Build a FakeBioImage, defaulting absent accessors to empty objects.""" + return FakeBioImage( + scale=scale if scale is not None else SimpleNamespace(), + dimension_properties=( + dimension_properties + if dimension_properties is not None + else SimpleNamespace() + ), + metadata=metadata, + ome_metadata=ome_metadata, + ) + + +@pytest.fixture +def image(): + """An image with full physical metadata for the DIMS dimensions.""" + scale = SimpleNamespace(T=2.0, Z=0.5, Y=0.2, X=0.2) + dim_props = SimpleNamespace( + T=SimpleNamespace(unit='s'), + Z=SimpleNamespace(unit='µm'), + Y=SimpleNamespace(unit='µm'), + X=SimpleNamespace(unit='µm'), + ) + return make_fake_image( + scale=scale, + dimension_properties=dim_props, + metadata={'source': 'fake'}, + ome_metadata={'version': '0.4'}, + ) + + +class TestAxisLabels: + def test_returns_typed_result(self, image): + meta = build_layer_metadata(image, DIMS) + assert isinstance(meta, LayerMetadata) + + def test_excludes_channel_and_samples(self, image): + meta = build_layer_metadata(image, DIMS_WITH_SAMPLES) + assert meta.axis_labels == ('T', 'Z', 'Y', 'X') + + def test_preserves_dims_order(self, image): + meta = build_layer_metadata(image, ('Y', 'X')) + assert meta.axis_labels == ('Y', 'X') + + +class TestScale: + def test_resolved_per_axis(self, image): + meta = build_layer_metadata(image, DIMS) + assert meta.scale == (2.0, 0.5, 0.2, 0.2) + + def test_defaults_to_one_for_missing_axis(self, image): + image._scale = SimpleNamespace(T=2.0) # only T is known + meta = build_layer_metadata(image, DIMS) + assert meta.scale == (2.0, 1.0, 1.0, 1.0) + + @pytest.mark.parametrize('exc', [AttributeError, KeyError, TypeError]) + def test_falls_back_to_ones_when_scale_unavailable(self, image, exc): + image._scale = exc + meta = build_layer_metadata(image, DIMS) + assert meta.scale == (1.0, 1.0, 1.0, 1.0) + + +class TestUnits: + def test_resolved_per_axis(self, image): + meta = build_layer_metadata(image, DIMS) + assert meta.units == ('s', 'µm', 'µm', 'µm') + + def test_none_for_axis_without_prop(self, image): + image._dimension_properties = SimpleNamespace( + T=SimpleNamespace(unit='s') + ) + meta = build_layer_metadata(image, DIMS) + assert meta.units == ('s', None, None, None) + + @pytest.mark.parametrize('exc', [AttributeError, KeyError, TypeError]) + def test_falls_back_to_nones_when_properties_unavailable(self, image, exc): + image._dimension_properties = exc + meta = build_layer_metadata(image, DIMS) + assert meta.units == (None, None, None, None) + + +class TestMetadata: + def test_contains_image_and_raw_metadata(self, image): + meta = build_layer_metadata(image, DIMS) + assert meta.metadata['bioimage'] is image + assert meta.metadata['raw_image_metadata'] == {'source': 'fake'} + + def test_includes_ome_metadata_when_available(self, image): + meta = build_layer_metadata(image, DIMS) + assert meta.metadata['ome_metadata'] == {'version': '0.4'} + + def test_omits_ome_metadata_on_notimplemented(self, image): + image._ome_metadata = NotImplementedError + meta = build_layer_metadata(image, DIMS) + assert 'ome_metadata' not in meta.metadata + assert 'raw_image_metadata' in meta.metadata + + @pytest.mark.parametrize('exc', [ValueError, TypeError, KeyError]) + def test_warns_but_keeps_raw_on_unparseable_ome(self, image, exc, caplog): + image._ome_metadata = exc + with caplog.at_level( + logging.WARNING, logger='ndevio.utils._layer_metadata' + ): + meta = build_layer_metadata(image, DIMS) + assert 'raw_image_metadata' in meta.metadata + assert 'ome_metadata' not in meta.metadata + assert any( + 'Could not parse OME metadata' in r.message for r in caplog.records + ) + + +class TestAlignment: + def test_scale_units_aligned_with_axis_labels(self, image): + meta = build_layer_metadata(image, DIMS) + assert len(meta.scale) == len(meta.axis_labels) + assert len(meta.units) == len(meta.axis_labels) From 40141e8e232139641211355930766ed6444e49f4 Mon Sep 17 00:00:00 2001 From: Tim Monko Date: Wed, 5 Aug 2026 16:26:50 -0500 Subject: [PATCH 2/2] tests for nImage.layer_* metadata properties --- tests/test_nimage.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_nimage.py b/tests/test_nimage.py index 8cd74a7..0d90131 100644 --- a/tests/test_nimage.py +++ b/tests/test_nimage.py @@ -320,6 +320,50 @@ def test_nimage_init_with_various_formats( ) +# ============================================================================= +# Tests for the layer_* metadata properties +# ============================================================================= + + +class TestLayerMetadataProperties: + """Direct tests of the nImage.layer_* metadata property accessors.""" + + def test_layer_axis_labels_exclude_channel_and_singleton( + self, resources_dir: Path + ): + # cells3d2ch is (T=1, C=2, Z=60, Y=66, X=85): squeeze drops T, + # C is excluded from the exposed axis labels. + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + assert img.layer_axis_labels == ('Z', 'Y', 'X') + + def test_layer_scale_and_units_aligned_with_axis_labels( + self, resources_dir: Path + ): + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + labels = img.layer_axis_labels + assert len(img.layer_scale) == len(labels) + assert len(img.layer_units) == len(labels) + + def test_layer_metadata_contains_bioimage_and_raw( + self, resources_dir: Path + ): + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + meta = img.layer_metadata + assert meta['bioimage'] is img + assert 'raw_image_metadata' in meta + assert 'ome_metadata' in meta + + def test_properties_match_layer_data_tuple_metadata( + self, resources_dir: Path + ): + img = nImage(resources_dir / CELLS3D2CH_OME_TIFF) + _, meta, _ = img.get_layer_data_tuples()[0] + assert meta['scale'] == img.layer_scale + assert meta['axis_labels'] == img.layer_axis_labels + assert meta['units'] == img.layer_units + assert meta['metadata'] is img.layer_metadata + + # ============================================================================= # Tests for get_layer_data_tuples # =============================================================================