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
83 changes: 25 additions & 58 deletions src/ndevio/nimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -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, ...]:
Expand All @@ -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, ...]:
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
133 changes: 133 additions & 0 deletions src/ndevio/utils/_layer_metadata.py
Original file line number Diff line number Diff line change
@@ -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']
44 changes: 44 additions & 0 deletions tests/test_nimage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# =============================================================================
Expand Down
Loading