diff --git a/.github/workflows/omero_plugin.yml b/.github/workflows/omero_plugin.yml index 7ba70a3d..691e5b6c 100644 --- a/.github/workflows/omero_plugin.yml +++ b/.github/workflows/omero_plugin.yml @@ -27,8 +27,9 @@ jobs: - name: Checkout omero-test-infra uses: actions/checkout@master with: - repository: ome/omero-test-infra + repository: jburel/omero-test-infra path: .omero - ref: ${{ secrets.OMERO_TEST_INFRA_REF }} + ref: python3.12 + # ref: ${{ secrets.OMERO_TEST_INFRA_REF }} - name: Build and run OMERO tests run: .omero/docker $STAGE diff --git a/.isort.cfg b/.isort.cfg index d5a6ee5d..6fc860a1 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,2 @@ [settings] -known_third_party = dask,numpy,ome_zarr,omero,omero_rois,omero_version,omero_zarr,pytest,setuptools,skimage,zarr +known_third_party = dask,numcodecs,numpy,ome_zarr,omero,omero_rois,omero_version,omero_zarr,pytest,setuptools,skimage,zarr diff --git a/README.rst b/README.rst index de939967..f554c367 100644 --- a/README.rst +++ b/README.rst @@ -86,6 +86,9 @@ To export Images or Plates via the OMERO API:: # Plate will be saved in current directory as 2.ome.zarr $ omero zarr export Plate:2 + # Specify the OME-Zarr format, e.g. 0.4. Default is 0.5 + $ omero zarr --format 0.4 export Image:1 + # Use the Image or Plate 'name' to save e.g. my_image.ome.zarr $ omero zarr --name_by name export Image:1 @@ -96,7 +99,7 @@ To export Images or Plates via the OMERO API:: $ omero zarr export Image:1 --tile_width 256 --tile_height 256 -NB: If the connection to OMERO is lost and the Image is partially exported, +NB: If the connection to OMERO is lost and the Image or Plate is partially exported, re-running the command will attempt to complete the export. To export images via bioformats2raw we use the ```--bf``` flag:: diff --git a/setup.py b/setup.py index ed5aad37..084f4e60 100755 --- a/setup.py +++ b/setup.py @@ -51,7 +51,10 @@ def get_long_description() -> str: author="The Open Microscopy Team", author_email="", python_requires=">=3", - install_requires=["omero-py>=5.6.0", "ome-zarr>=0.5.0,<0.12.0"], + install_requires=[ + "omero-py>=5.6.0", + "ome-zarr>=0.12.0", + ], long_description=long_description, keywords=["OMERO.CLI", "plugin"], url="https://github.com/ome/omero-cli-zarr/", diff --git a/src/omero_zarr/__init__.py b/src/omero_zarr/__init__.py index f9feaf6c..0a59fbc7 100644 --- a/src/omero_zarr/__init__.py +++ b/src/omero_zarr/__init__.py @@ -16,12 +16,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from ome_zarr.format import CurrentFormat - from ._version import version as __version__ -ngff_version = CurrentFormat().version - __all__ = [ "__version__", ] diff --git a/src/omero_zarr/cli.py b/src/omero_zarr/cli.py index 98bb79aa..300a61b3 100644 --- a/src/omero_zarr/cli.py +++ b/src/omero_zarr/cli.py @@ -26,8 +26,8 @@ from omero.cli import CLI, BaseControl, Parser, ProxyStringType from omero.gateway import BlitzGateway, BlitzObjectWrapper from omero.model import ImageI, PlateI -from zarr.hierarchy import open_group -from zarr.storage import FSStore +from zarr.api.synchronous import open_group +from zarr.storage import LocalStore from .masks import ( MASK_DTYPE_SIZE, @@ -323,6 +323,12 @@ def _configure(self, parser: Parser) -> None: subcommand.add_argument( "--output", type=str, default="", help="The output directory" ) + subcommand.add_argument( + "--format", + type=str, + choices=["0.4", "0.5"], + help="OME-Zarr version. Default is '0.5'", + ) for subcommand in (polygons, masks): subcommand.add_argument( "--overlaps", @@ -399,6 +405,12 @@ def export(self, args: argparse.Namespace) -> None: if isinstance(args.object, ImageI): image = self._lookup(self.gateway, "Image", args.object.id) if args.bf or args.bfpath: + if args.format and args.format != "0.4": + self.ctx.die( + 110, + "bioformats2raw does not support OME-Zarr format %s" + % args.format, + ) self._bf_export(image, args) else: image_to_zarr(image, args) @@ -484,7 +496,7 @@ def _bf_export(self, image: BlitzObjectWrapper, args: argparse.Namespace) -> Non self.ctx.out(f"Image exported to {image_target.resolve()}") # Add OMERO metadata - store = FSStore( + store = LocalStore( str(image_target.resolve()), auto_mkdir=False, normalize_keys=False, diff --git a/src/omero_zarr/masks.py b/src/omero_zarr/masks.py index e403a078..8e563f82 100644 --- a/src/omero_zarr/masks.py +++ b/src/omero_zarr/masks.py @@ -26,25 +26,17 @@ from typing import Dict, List, Optional, Set, Tuple import numpy as np -import omero.clients # noqa +import omero.clients from ome_zarr.conversions import int_to_rgba_255 -from ome_zarr.io import parse_url -from ome_zarr.reader import Multiscales, Node from ome_zarr.scale import Scaler from ome_zarr.types import JSONDict -from ome_zarr.writer import write_multiscale_labels +from ome_zarr.writer import get_metadata, write_multiscale_labels from omero.model import MaskI, PolygonI from omero.rtypes import unwrap from skimage.draw import polygon as sk_polygon -from zarr.hierarchy import open_group +from zarr.api.synchronous import open_group -from .util import ( - get_zarr_name, - marshal_axes, - marshal_transformations, - open_store, - print_status, -) +from .util import get_zarr_name, marshal_axes, marshal_transformations, print_status LOGGER = logging.getLogger("omero_zarr.masks") @@ -96,6 +88,7 @@ def plate_shapes_to_zarr( args.overlaps, args.output, args.name_by, + args.format, ) count = 0 @@ -195,6 +188,7 @@ def image_shapes_to_zarr( args.overlaps, args.output, args.name_by, + args.format, ) if args.style == "split": @@ -231,6 +225,7 @@ def __init__( overlaps: str = "error", output: Optional[str] = None, name_by: str = "id", + ome_zarr_fmt: Optional[str] = None, ) -> None: self.dtype = dtype self.path = path @@ -241,6 +236,7 @@ def __init__( self.overlaps = overlaps self.output = output self.name_by = name_by + self.format = ome_zarr_fmt if image: self.image = image self.size_t = image.getSizeT() @@ -324,14 +320,13 @@ def save(self, masks: List[omero.model.Shape], name: str) -> None: image_path = source_image if self.output: image_path = os.path.join(self.output, source_image) - src = parse_url(image_path) - assert src, f"Source image does not exist at {image_path}" - input_pyramid = Node(src, []) - assert input_pyramid.load(Multiscales), "No multiscales metadata found" - input_pyramid_levels = len(input_pyramid.data) + img_group = open_group(image_path) + img_attrs = get_metadata(img_group) + assert "multiscales" in img_attrs, "No multiscales metadata found" + input_pyramid_levels = len(img_attrs["multiscales"][0]["datasets"]) - store = open_store(image_path) - label_group = open_group(store) + # If image is zarr v2, labels will be too + write_group = open_group(image_path, mode="a") _mask_shape: List[int] = list(self.image_shape) mask_shape: Tuple[int, ...] = tuple(_mask_shape) @@ -385,7 +380,7 @@ def save(self, masks: List[omero.model.Shape], name: str) -> None: write_multiscale_labels( label_pyramid, - label_group, + write_group, name, axes=axes, coordinate_transformations=transformations, diff --git a/src/omero_zarr/raw_pixels.py b/src/omero_zarr/raw_pixels.py index ce667253..1f4f5120 100644 --- a/src/omero_zarr/raw_pixels.py +++ b/src/omero_zarr/raw_pixels.py @@ -26,8 +26,13 @@ import numpy as np import omero.clients # noqa import omero.gateway # required to allow 'from omero_zarr import raw_pixels' +from numcodecs import Blosc from ome_zarr.dask_utils import resize as da_resize +from ome_zarr.format import CurrentFormat, FormatV05, format_from_version +from ome_zarr.io import parse_url from ome_zarr.writer import ( + add_metadata, + check_format, write_multiscales_metadata, write_plate_metadata, write_well_metadata, @@ -44,25 +49,23 @@ PixelsTypeuint32, ) from omero.rtypes import unwrap -from zarr.hierarchy import Group, open_group +from zarr.api.synchronous import open_group +from zarr.core.group import Group from . import __version__ -from . import ngff_version as VERSION -from .util import ( - get_zarr_name, - marshal_axes, - marshal_transformations, - open_store, - print_status, -) +from .util import get_zarr_name, marshal_axes, marshal_transformations, print_status def image_to_zarr(image: omero.gateway.ImageWrapper, args: argparse.Namespace) -> None: tile_width = args.tile_width tile_height = args.tile_height name = get_zarr_name(image, args.output, args.name_by) - print(f"Exporting to {name} ({VERSION})") - store = open_store(name) + if args.format is not None: + fmt = format_from_version(args.format) + else: + fmt = CurrentFormat() + print(f"Exporting to {name} ({fmt.version})") + store = parse_url(name, mode="w", fmt=fmt).store root = open_group(store) add_image(image, root, tile_width=tile_width, tile_height=tile_height) add_omero_metadata(root, image) @@ -100,7 +103,7 @@ def add_image( for dataset, transform in zip(datasets, transformations): dataset["coordinateTransformations"] = transform - write_multiscales_metadata(parent, datasets, axes=axes) + write_multiscales_metadata(parent, fmt=FormatV05(), datasets=datasets, axes=axes) return (level_count, axes) @@ -112,6 +115,7 @@ def add_raw_image( tile_width: Optional[int] = None, tile_height: Optional[int] = None, ) -> List[str]: + fmt = check_format(parent) pixels = image.getPrimaryPixels() omero_dtype = image.getPixelsType() pixelTypes = { @@ -152,12 +156,20 @@ def add_raw_image( dims = [dim for dim in [size_t, size_c, size_z] if dim != 1] shape = tuple(dims + [size_y, size_x]) chunks = tuple([1] * len(dims) + [tile_height, tile_width]) - zarray = parent.require_dataset( + kwargs = {} + dim_names = [ax["name"] for ax in marshal_axes(image)] + if fmt.zarr_format == 3: + kwargs["dimension_names"] = dim_names + else: + kwargs["compressor"] = Blosc(cname="zstd", clevel=5, shuffle=Blosc.SHUFFLE) + zarray = parent.require_array( path, shape=shape, exact=True, chunks=chunks, dtype=d_type, + chunk_key_encoding=fmt.chunk_key_encoding, + **kwargs, ) # Need to be sure that dims match (if array already existed) @@ -198,29 +210,32 @@ def add_raw_image( if existing_data.max() == 0: print("loading Tile...") tile = pixels.getTile(z, c, t, tile_dims) + print("----------------> Tile max:", tile.max()) zarray[tuple(indices)] = tile paths = [str(level) for level in range(level_count)] - downsample_pyramid_on_disk(parent, paths) + downsample_pyramid_on_disk(parent, paths, dim_names) return paths -def downsample_pyramid_on_disk(parent: Group, paths: List[str]) -> List[str]: +def downsample_pyramid_on_disk( + parent: Group, paths: List[str], dim_names: Optional[List[str]] = None +) -> List[str]: """ Takes a high-resolution Zarr array at paths[0] in the zarr group and down-samples it by a factor of 2 for each of the other paths """ - group_path = parent.store.path - image_path = os.path.join(group_path, parent.path) - print("downsample_pyramid_on_disk", image_path) + group_path = str(parent.store_path) + fmt = check_format(parent) + print("downsample_pyramid_on_disk", group_path) for count, path in enumerate(paths[1:]): - target_path = os.path.join(image_path, path) + target_path = os.path.join(group_path, path) if os.path.exists(target_path): print("path exists: %s" % target_path) continue # open previous resolution from disk via dask... - path_to_array = os.path.join(image_path, paths[count]) + path_to_array = os.path.join(group_path, paths[count]) dask_image = da.from_zarr(path_to_array) # resize in X and Y @@ -231,12 +246,21 @@ def downsample_pyramid_on_disk(parent: Group, paths: List[str]) -> List[str]: dask_image, tuple(dims), preserve_range=True, anti_aliasing=False ) + options = {"zarr_format": fmt.zarr_format} + if fmt.zarr_format == 2: + options["dimension_separator"] = "/" + options["compressor"] = Blosc(cname="zstd", clevel=5, shuffle=Blosc.SHUFFLE) + else: + options["chunk_key_encoding"] = fmt.chunk_key_encoding + if dim_names is not None: + options["dimension_names"] = dim_names + # write to disk da.to_zarr( arr=output, - url=image_path, + url=parent.store_path, component=path, - dimension_separator=parent._store._dimension_separator, + **options, ) return paths @@ -270,9 +294,16 @@ def plate_to_zarr(plate: omero.gateway._PlateWrapper, args: argparse.Namespace) total = n_rows * n_cols * (n_fields[1] - n_fields[0] + 1) name = get_zarr_name(plate, args.output, args.name_by) - store = open_store(name) - print(f"Exporting to {name} ({VERSION})") + # store = open_store(name) + + if args.format is not None: + fmt = format_from_version(args.format) + else: + fmt = CurrentFormat() + # Use fmt=FormatV04() in parse_url() to write v0.4 format (zarr v2) + store = parse_url(name, mode="w", fmt=fmt).store root = open_group(store) + print(f"Exporting to {name} ({fmt.version})") count = 0 max_fields = 0 @@ -310,7 +341,7 @@ def plate_to_zarr(plate: omero.gateway._PlateWrapper, args: argparse.Namespace) field_info["acquisition"] = ac.id fields.append(field_info) row_group = root.require_group(row) - col_group = row_group.require_group(col) + col_group = row_group.require_group(str(col)) field_group = col_group.require_group(field_name) add_image(img, field_group) add_omero_metadata(field_group, img) @@ -337,22 +368,25 @@ def plate_to_zarr(plate: omero.gateway._PlateWrapper, args: argparse.Namespace) def add_omero_metadata(zarr_root: Group, image: omero.gateway.ImageWrapper) -> None: - image_data = { - "id": 1, + fmt = check_format(zarr_root) + omero_data = { "channels": [channelMarshal(c) for c in image.getChannels()], "rdefs": { "model": (image.isGreyscaleRenderingModel() and "greyscale" or "color"), "defaultZ": image._re.getDefaultZ(), "defaultT": image._re.getDefaultT(), }, - "version": VERSION, } - zarr_root.attrs["omero"] = image_data + if fmt.zarr_format == 2: + omero_data["version"] = fmt.version + add_metadata(zarr_root, {"omero": omero_data}) image._closeRE() def add_toplevel_metadata(zarr_root: Group) -> None: - zarr_root.attrs["_creator"] = {"name": "omero-zarr", "version": __version__} + add_metadata( + zarr_root, {"_creator": {"name": "omero-zarr", "version": __version__}} + ) def channelMarshal(channel: Channel) -> Dict[str, Any]: diff --git a/src/omero_zarr/util.py b/src/omero_zarr/util.py index b5629294..76f3a057 100644 --- a/src/omero_zarr/util.py +++ b/src/omero_zarr/util.py @@ -21,7 +21,7 @@ from typing import Dict, List, Optional from omero.gateway import BlitzObjectWrapper, ImageWrapper -from zarr.storage import FSStore +from zarr.storage import LocalStore def print_status(t0: int, t: int, count: int, total: int) -> None: @@ -43,16 +43,13 @@ def print_status(t0: int, t: int, count: int, total: int) -> None: print(status, end="\r", flush=True) -def open_store(name: str) -> FSStore: +def open_store(name: str) -> LocalStore: """ - Create an FSStore instance that supports nested storage of chunks. + Create an LocalStore instance that supports nested storage of chunks. """ - return FSStore( + return LocalStore( name, - auto_mkdir=True, - key_separator="/", - normalize_keys=False, - mode="w", + read_only=False, ) diff --git a/src/omero_zarr/zarr_import.py b/src/omero_zarr/zarr_import.py index b02aadcc..343f7381 100644 --- a/src/omero_zarr/zarr_import.py +++ b/src/omero_zarr/zarr_import.py @@ -38,18 +38,14 @@ PixelsTypeuint32, ) from omero.rtypes import rbool, rdouble, rint, rlong, rstring -from zarr.core import Array -from zarr.creation import open_array -from zarr.errors import ArrayNotFoundError, GroupNotFoundError -from zarr.hierarchy import open_group -from zarr.storage import FSStore - -from .import_xml import full_import +from zarr.api.synchronous import open_array, open_group # TODO: support Zarr v3 - imports for get_omexml_bytes() -# from zarr.core.buffer import default_buffer_prototype -# from zarr.core.sync import sync +from zarr.core.buffer import default_buffer_prototype +from zarr.core.sync import sync +from zarr.errors import ArrayNotFoundError, GroupNotFoundError +from .import_xml import full_import AWS_DEFAULT_ENDPOINT = "s3.us-east-1.amazonaws.com" @@ -70,16 +66,13 @@ } -def get_omexml_bytes(store: zarr.storage.Store) -> Optional[bytes]: +def get_omexml_bytes(store: zarr.storage.StoreLike) -> Optional[bytes]: # Zarr v3 get() is async. Need to sync to get the bytes - # rsp = store.get("OME/METADATA.ome.xml", prototype=default_buffer_prototype()) - # result = sync(rsp) - # if result is None: - # return None - # return result.to_bytes() - - # Zarr v2 - return store.get("OME/METADATA.ome.xml") + rsp = store.get("OME/METADATA.ome.xml", prototype=default_buffer_prototype()) + result = sync(rsp) + if result is None: + return None + return result.to_bytes() def format_s3_uri(uri: str, endpoint: str) -> str: @@ -96,10 +89,6 @@ def format_s3_uri(uri: str, endpoint: str) -> str: return f"{parsed_uri.scheme}" + "://" + endpoint + "/" + url + f"{parsed_uri.path}" -def load_array(store: zarr.storage.Store, path: Optional[str] = None) -> Array: - return open_array(store=store, mode="r", path=path) - - def load_attrs(store: zarr.storage.StoreLike, path: Optional[str] = None) -> dict: """ Load the attrs from the root group or path subgroup @@ -112,7 +101,7 @@ def load_attrs(store: zarr.storage.StoreLike, path: Optional[str] = None) -> dic def parse_image_metadata( - store: zarr.storage.Store, img_attrs: dict, image_path: Optional[str] = None + store: zarr.storage.StoreLike, img_attrs: dict, image_path: Optional[str] = None ) -> tuple: """ Parse the image metadata @@ -122,7 +111,7 @@ def parse_image_metadata( if image_path is not None: array_path = image_path.rstrip("/") + "/" + array_path # load .zarray from path to know the dimension - array_data = load_array(store, array_path) + array_data = open_array(store=store, mode="r", path=array_path) sizes = {} shape = array_data.shape axes = multiscale_attrs.get("axes") @@ -140,7 +129,7 @@ def parse_image_metadata( def create_image( conn: BlitzGateway, - store: zarr.storage.Store, + store: zarr.storage.StoreLike, image_attrs: dict, object_name: str, families: list, @@ -335,7 +324,7 @@ def load_models(conn: BlitzGateway) -> list: def import_image( conn: BlitzGateway, - store: zarr.storage.Store, + store: zarr.storage.StoreLike, kwargs: dict, img_attrs: Optional[dict] = None, image_path: Optional[str] = None, @@ -502,10 +491,10 @@ def import_zarr( nosignrequest = kwargs.get("nosignrequest", False) validate_endpoint(endpoint) store = None - if uri.startswith("/"): - # store = zarr.storage.LocalStore(uri, read_only=True) - store = zarr.storage.NestedDirectoryStore(uri) - else: + args = {} + + # Let zarr create the store based on the uri + if not uri.startswith("/"): storage_options: Dict[str, Any] = {} if nosignrequest: storage_options["anon"] = True @@ -513,12 +502,10 @@ def import_zarr( if endpoint: storage_options["client_kwargs"] = {"endpoint_url": endpoint} - # if FsspecStore is not None: - # store = FsspecStore.from_url( - # uri, read_only=True, storage_options=storage_options - # ) - # else: - store = FSStore(uri, mode="r", **storage_options) + args["storage_options"] = storage_options + + root_group = open_group(uri, mode="r", **args) + store = root_group.store zattrs = load_attrs(store) objs = [] diff --git a/test/integration/clitest/test_export.py b/test/integration/clitest/test_export.py index d6145ccf..ede1b0a3 100644 --- a/test/integration/clitest/test_export.py +++ b/test/integration/clitest/test_export.py @@ -20,7 +20,7 @@ import json from pathlib import Path -from typing import List +from typing import List, Optional import dask.array as da import pytest @@ -30,9 +30,10 @@ from omero.testlib.cli import AbstractCLITest from omero_rois import mask_from_binary_image from omero_zarr.cli import ZarrControl +from pytest import FixtureRequest -class TestRender(AbstractCLITest): +class TestExport(AbstractCLITest): def setup_method(self, method: str) -> None: """Set up the test.""" @@ -40,6 +41,10 @@ def setup_method(self, method: str) -> None: self.cli.register("zarr", ZarrControl, "TEST") self.args += ["zarr"] + @pytest.fixture(params=("0.4", "0.5", None)) + def version(self, request: FixtureRequest) -> Optional[str]: + return request.param + def add_shape_to_image(self, shape: PolygonI, image: ImageI) -> None: roi = RoiI() roi.setImage(image) @@ -84,8 +89,13 @@ def add_polygons_to_plate(self, plate: PlateWrapper) -> None: # ======================================================================== @pytest.mark.parametrize("name_by", ["id", "name"]) + @pytest.mark.parametrize("ome_zarr_fmt", [None, "0.4", "0.5"]) def test_export_zarr( - self, capsys: pytest.CaptureFixture, tmp_path: Path, name_by: str + self, + capsys: pytest.CaptureFixture, + tmp_path: Path, + name_by: str, + ome_zarr_fmt: str, ) -> None: """Test export of a Zarr image.""" sizec = 2 @@ -99,6 +109,8 @@ def test_export_zarr( "--name_by", name_by, ] + if ome_zarr_fmt: + exp_args += ["--format", ome_zarr_fmt] self.cli.invoke( self.args + exp_args, strict=True, @@ -119,21 +131,39 @@ def test_export_zarr( assert len(list(tmp_path.iterdir())) == 1 assert (tmp_path / zarr_name).is_dir() - attrs_text = (tmp_path / zarr_name / ".zattrs").read_text(encoding="utf-8") + if ome_zarr_fmt == "0.4": + attrs_name = ".zattrs" + arr_name = ".zarray" + else: + attrs_name = "zarr.json" + arr_name = "zarr.json" + + attrs_text = (tmp_path / zarr_name / attrs_name).read_text(encoding="utf-8") attrs_json = json.loads(attrs_text) print(attrs_json) + + if ome_zarr_fmt != "0.4": + attrs_json = attrs_json.get("attributes", {}).get("ome") + assert "multiscales" in attrs_json assert len(attrs_json["omero"]["channels"]) == sizec assert attrs_json["omero"]["channels"][0]["window"]["min"] == 0 assert attrs_json["omero"]["channels"][0]["window"]["max"] == 255 - arr_text = (tmp_path / zarr_name / "0" / ".zarray").read_text(encoding="utf-8") + arr_text = (tmp_path / zarr_name / "0" / arr_name).read_text(encoding="utf-8") arr_json = json.loads(arr_text) assert arr_json["shape"] == [sizec, 512, 512] + if ome_zarr_fmt != "0.4": + assert "dimension_names" in arr_json @pytest.mark.parametrize("name_by", ["id", "name"]) + @pytest.mark.parametrize("ome_zarr_fmt", ["0.4", "0.5"]) def test_export_plate( - self, capsys: pytest.CaptureFixture, tmp_path: Path, name_by: str + self, + capsys: pytest.CaptureFixture, + tmp_path: Path, + name_by: str, + ome_zarr_fmt: str, ) -> None: plates = self.import_plates( @@ -152,6 +182,8 @@ def test_export_plate( str(tmp_path), "--name_by", name_by, + "--format", + ome_zarr_fmt, ] self.cli.invoke( self.args + exp_args, @@ -170,22 +202,39 @@ def test_export_plate( assert "Exporting to" in all_lines assert "Finished" in all_lines assert (tmp_path / zarr_name).is_dir() - attrs_text = (tmp_path / zarr_name / ".zattrs").read_text(encoding="utf-8") + + if ome_zarr_fmt == "0.4": + attrs_name = ".zattrs" + arr_name = ".zarray" + else: + attrs_name = "zarr.json" + arr_name = "zarr.json" + + attrs_text = (tmp_path / zarr_name / attrs_name).read_text(encoding="utf-8") attrs_json = json.loads(attrs_text) print(attrs_json) + + if ome_zarr_fmt != "0.4": + attrs_json = attrs_json.get("attributes", {}).get("ome") + assert len(attrs_json["plate"]["wells"]) == 4 assert attrs_json["plate"]["rows"] == [{"name": "A"}, {"name": "B"}] assert attrs_json["plate"]["columns"] == [{"name": "1"}, {"name": "2"}] - arr_text = (tmp_path / zarr_name / "A" / "1" / "0" / "0" / ".zarray").read_text( + arr_text = (tmp_path / zarr_name / "A" / "1" / "0" / "0" / arr_name).read_text( encoding="utf-8" ) arr_json = json.loads(arr_text) assert arr_json["shape"] == [512, 512] @pytest.mark.parametrize("name_by", ["id", "name"]) + @pytest.mark.parametrize("ome_zarr_fmt", ["0.4", "0.5"]) def test_export_masks( - self, capsys: pytest.CaptureFixture, tmp_path: Path, name_by: str + self, + capsys: pytest.CaptureFixture, + tmp_path: Path, + name_by: str, + ome_zarr_fmt: str, ) -> None: """Test export of a Zarr image.""" images = self.import_fake_file(sizeC=2, client=self.client) @@ -209,7 +258,13 @@ def test_export_masks( print("tmp_path", tmp_path) - img_args = [f"Image:{img_id}", "--output", str(tmp_path)] + img_args = [ + f"Image:{img_id}", + "--output", + str(tmp_path), + "--format", + ome_zarr_fmt, + ] self.cli.invoke( self.args + ["export", "--name_by", name_by] + img_args, strict=True, @@ -234,21 +289,35 @@ def test_export_masks( assert "Finished" in all_lines assert "Found 1 mask shapes in 1 ROIs" in all_lines - labels_text = (tmp_path / zarr_name / "labels" / "0" / ".zattrs").read_text( + if ome_zarr_fmt == "0.4": + attrs_name = ".zattrs" + arr_name = ".zarray" + else: + attrs_name = "zarr.json" + arr_name = "zarr.json" + + labels_text = (tmp_path / zarr_name / "labels" / "0" / attrs_name).read_text( encoding="utf-8" ) labels_json = json.loads(labels_text) + if ome_zarr_fmt != "0.4": + labels_json = labels_json.get("attributes", {}).get("ome") assert labels_json["image-label"]["colors"] == [{"label-value": 1, "rgba": red}] - arr_text = (tmp_path / zarr_name / "labels" / "0" / "0" / ".zarray").read_text( + arr_text = (tmp_path / zarr_name / "labels" / "0" / "0" / arr_name).read_text( encoding="utf-8" ) arr_json = json.loads(arr_text) assert arr_json["shape"] == [1, 512, 512] @pytest.mark.parametrize("name_by", ["id", "name"]) + @pytest.mark.parametrize("ome_zarr_fmt", ["0.4", "0.5"]) def test_export_plate_polygons( - self, capsys: pytest.CaptureFixture, tmp_path: Path, name_by: str + self, + capsys: pytest.CaptureFixture, + tmp_path: Path, + name_by: str, + ome_zarr_fmt: str, ) -> None: plates = self.import_plates( @@ -272,6 +341,8 @@ def test_export_plate_polygons( str(tmp_path), "--name_by", name_by, + "--format", + ome_zarr_fmt, ] self.cli.invoke( self.args + ["export"] + extra_args, @@ -289,11 +360,20 @@ def test_export_plate_polygons( print("tmp_path", tmp_path) + if ome_zarr_fmt == "0.4": + attrs_name = ".zattrs" + else: + attrs_name = "zarr.json" + def check_well(well_path: Path, label_count: int) -> None: - label_text = (well_path / "0" / "labels" / "0" / ".zattrs").read_text( + label_text = (well_path / "0" / "labels" / "0" / attrs_name).read_text( encoding="utf-8" ) label_image_json = json.loads(label_text) + + if ome_zarr_fmt != "0.4": + label_image_json = label_image_json.get("attributes", {}).get("ome") + assert "multiscales" in label_image_json assert "image-label" in label_image_json datasets = label_image_json["multiscales"][0]["datasets"]