diff --git a/ngff_zarr/__init__.py b/ngff_zarr/__init__.py index 7eed3450..5ead1d52 100644 --- a/ngff_zarr/__init__.py +++ b/ngff_zarr/__init__.py @@ -32,6 +32,9 @@ Transform, Dataset, Metadata, + Omero, + OmeroChannel, + OmeroWindow, ) __all__ = [ @@ -67,4 +70,7 @@ "Transform", "Dataset", "Metadata", + "Omero", + "OmeroChannel", + "OmeroWindow", ] diff --git a/ngff_zarr/from_ngff_zarr.py b/ngff_zarr/from_ngff_zarr.py index 2c8bc85f..36e85d11 100644 --- a/ngff_zarr/from_ngff_zarr.py +++ b/ngff_zarr/from_ngff_zarr.py @@ -16,7 +16,15 @@ from .ngff_image import NgffImage from .to_multiscales import Multiscales -from .v04.zarr_metadata import Axis, Dataset, Scale, Translation +from .v04.zarr_metadata import ( + Axis, + Dataset, + Scale, + Translation, + Omero, + OmeroChannel, + OmeroWindow, +) from .validate import validate as validate_ngff zarr_version = packaging.version.parse(zarr.__version__) @@ -145,9 +153,29 @@ def from_ngff_zarr( Axis(name="y", type="space"), Axis(name="x", type="space"), ] + coordinateTransformations = None if "coordinateTransformations" in metadata: coordinateTransformations = metadata["coordinateTransformations"] + + omero = None + if "omero" in root.attrs: + omero_data = root.attrs["omero"] + omero = Omero( + channels=[ + OmeroChannel( + color=channel["color"], + window=OmeroWindow( + min=channel["window"]["min"], + max=channel["window"]["max"], + start=channel["window"]["start"], + end=channel["window"]["end"], + ), + ) + for channel in omero_data["channels"] + ] + ) + if version == "0.5": from .v05.zarr_metadata import Metadata @@ -156,6 +184,7 @@ def from_ngff_zarr( datasets=datasets, name=name, coordinateTransformations=coordinateTransformations, + omero=omero, ) else: from .v04.zarr_metadata import Metadata @@ -166,6 +195,7 @@ def from_ngff_zarr( name=name, version=metadata["version"], coordinateTransformations=coordinateTransformations, + omero=omero, ) return Multiscales(images, metadata) diff --git a/ngff_zarr/to_ngff_zarr.py b/ngff_zarr/to_ngff_zarr.py index 06f1c44d..21092362 100644 --- a/ngff_zarr/to_ngff_zarr.py +++ b/ngff_zarr/to_ngff_zarr.py @@ -47,6 +47,9 @@ def _pop_metadata_optionals(metadata_dict): if metadata_dict["coordinateTransformations"] is None: metadata_dict.pop("coordinateTransformations") + if metadata_dict["omero"] is None: + metadata_dict.pop("omero") + return metadata_dict @@ -231,6 +234,9 @@ def to_ngff_zarr( **format_kwargs, ) + if "omero" in metadata_dict: + root.attrs["omero"] = metadata_dict.pop("omero") + if version != "0.4": # RFC 2, Zarr 3 root.attrs["ome"] = {"version": version, "multiscales": [metadata_dict]} diff --git a/ngff_zarr/v04/zarr_metadata.py b/ngff_zarr/v04/zarr_metadata.py index 273b4985..5cd1f850 100644 --- a/ngff_zarr/v04/zarr_metadata.py +++ b/ngff_zarr/v04/zarr_metadata.py @@ -2,6 +2,7 @@ from typing import List, Optional, Union from typing_extensions import Literal +import re SupportedDims = Union[ Literal["c"], Literal["x"], Literal["y"], Literal["z"], Literal["t"] @@ -165,10 +166,34 @@ class Dataset: coordinateTransformations: List[Transform] +@dataclass +class OmeroWindow: + min: float + max: float + start: float + end: float + + +@dataclass +class OmeroChannel: + color: str + window: OmeroWindow + + def validate_color(self): + if not re.fullmatch(r"[0-9A-Fa-f]{6}", self.color): + raise ValueError(f"Invalid color '{self.color}'. Must be 6 hex digits.") + + +@dataclass +class Omero: + channels: List[OmeroChannel] + + @dataclass class Metadata: axes: List[Axis] datasets: List[Dataset] coordinateTransformations: Optional[List[Transform]] + omero: Optional[Omero] = None name: str = "image" version: str = "0.4" diff --git a/ngff_zarr/v05/zarr_metadata.py b/ngff_zarr/v05/zarr_metadata.py index 81de415d..4ae152dc 100644 --- a/ngff_zarr/v05/zarr_metadata.py +++ b/ngff_zarr/v05/zarr_metadata.py @@ -1,7 +1,7 @@ from typing import List, Optional from dataclasses import dataclass -from ..v04.zarr_metadata import Axis, Transform, Dataset +from ..v04.zarr_metadata import Axis, Transform, Dataset, Omero @dataclass @@ -9,4 +9,5 @@ class Metadata: axes: List[Axis] datasets: List[Dataset] coordinateTransformations: Optional[List[Transform]] + omero: Optional[Omero] = None name: str = "image" diff --git a/test/test_omero.py b/test/test_omero.py new file mode 100644 index 00000000..d5d3e809 --- /dev/null +++ b/test/test_omero.py @@ -0,0 +1,116 @@ +import pytest +import numpy as np +from zarr.storage import MemoryStore +from ngff_zarr import ( + Omero, + OmeroChannel, + OmeroWindow, + from_ngff_zarr, + to_ngff_image, + to_multiscales, + to_ngff_zarr, +) + +from ._data import test_data_dir + + +def test_read_omero(input_images): # noqa: ARG001 + dataset_name = "13457537" + store_path = test_data_dir / "input" / f"{dataset_name}.zarr" + multiscales = from_ngff_zarr(store_path, validate=True) + + omero = multiscales.metadata.omero + assert omero is not None + assert len(omero.channels) == 6 + + # Channel 0 + assert omero.channels[0].color == "FFFFFF" + assert omero.channels[0].window.min == 0.0 + assert omero.channels[0].window.max == 65535.0 + assert omero.channels[0].window.start == 0.0 + assert omero.channels[0].window.end == 1200.0 + + # Channel 1 + assert omero.channels[1].color == "FFFFFF" + assert omero.channels[1].window.min == 0.0 + assert omero.channels[1].window.max == 65535.0 + assert omero.channels[1].window.start == 0.0 + assert omero.channels[1].window.end == 1200.0 + + # Channel 2 + assert omero.channels[2].color == "FFFFFF" + assert omero.channels[2].window.min == 0.0 + assert omero.channels[2].window.max == 65535.0 + assert omero.channels[2].window.start == 0.0 + assert omero.channels[2].window.end == 1200.0 + + # Channel 3 + assert omero.channels[3].color == "FFFFFF" + assert omero.channels[3].window.min == 0.0 + assert omero.channels[3].window.max == 65535.0 + assert omero.channels[3].window.start == 0.0 + assert omero.channels[3].window.end == 1200.0 + + # Channel 4 + assert omero.channels[4].color == "0000FF" + assert omero.channels[4].window.min == 0.0 + assert omero.channels[4].window.max == 65535.0 + assert omero.channels[4].window.start == 0.0 + assert omero.channels[4].window.end == 5000.0 + + # Channel 5 + assert omero.channels[5].color == "FF0000" + assert omero.channels[5].window.min == 0.0 + assert omero.channels[5].window.max == 65535.0 + assert omero.channels[5].window.start == 0.0 + assert omero.channels[5].window.end == 100.0 + + +def test_write_omero(): + data = np.random.randint(0, 256, 262144).reshape((2, 32, 64, 64)).astype(np.uint8) + image = to_ngff_image(data, dims=["c", "z", "y", "x"]) + multiscales = to_multiscales(image, scale_factors=[2, 4], chunks=32) + + omero = Omero( + channels=[ + OmeroChannel( + color="008000", + window=OmeroWindow(min=0.0, max=255.0, start=10.0, end=150.0), + ), + OmeroChannel( + color="0000FF", + window=OmeroWindow(min=0.0, max=255.0, start=30.0, end=200.0), + ), + ] + ) + multiscales.metadata.omero = omero + + store = MemoryStore() + version = "0.4" + to_ngff_zarr(store, multiscales, version=version) + + multiscales_read = from_ngff_zarr(store, validate=True, version=version) + read_omero = multiscales_read.metadata.omero + + assert read_omero is not None + assert len(read_omero.channels) == 2 + assert read_omero.channels[0].color == "008000" + assert read_omero.channels[0].window.start == 10.0 + assert read_omero.channels[0].window.end == 150.0 + assert read_omero.channels[1].color == "0000FF" + assert read_omero.channels[1].window.start == 30.0 + assert read_omero.channels[1].window.end == 200.0 + + +def test_validate_color(): + valid_channel = OmeroChannel( + color="1A2B3C", window=OmeroWindow(min=0.0, max=255.0, start=0.0, end=100.0) + ) + # This should not raise an error + valid_channel.validate_color() + + invalid_channel = OmeroChannel( + color="ZZZZZZ", window=OmeroWindow(min=0.0, max=255.0, start=0.0, end=100.0) + ) + with pytest.raises(ValueError, match=r"Invalid color 'ZZZZZZ'"): + invalid_channel.validate_color()