diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a10f65d5..390fcf3a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,7 +6,7 @@ name: lint, style, and tests on: pull_request: branches: - - main + - "*" jobs: style: diff --git a/iohub/ngff/models.py b/iohub/ngff/models.py index cb2c91e9..46648712 100644 --- a/iohub/ngff/models.py +++ b/iohub/ngff/models.py @@ -2,7 +2,7 @@ """ Data model classes with validation for OME-NGFF metadata. -Developed against OME-NGFF v0.4 and ome-zarr v0.9 +Developed against OME-NGFF v0.4/0.5.2 and ome-zarr v0.9. Attributes are 'snake_case' with aliases to match NGFF names in JSON output. See https://ngff.openmicroscopy.org/0.4/index.html#naming-style @@ -219,7 +219,9 @@ class VersionMeta(MetaBase): """OME-NGFF spec version. Default is the current version (0.4).""" # SHOULD - version: Literal["0.1", "0.2", "0.3", "0.4"] = "0.4" + version: Literal["0.1", "0.2", "0.3", "0.4", "0.5"] | None = Field( + default=None, exclude=lambda v: v is None + ) class MultiScaleMeta(VersionMeta): diff --git a/iohub/ngff/nodes.py b/iohub/ngff/nodes.py index 92cf6f08..351eece2 100644 --- a/iohub/ngff/nodes.py +++ b/iohub/ngff/nodes.py @@ -16,8 +16,6 @@ import numpy as np import zarr.codecs -import zarr.storage -from numcodecs import Blosc from numpy.typing import ArrayLike, DTypeLike, NDArray from pydantic import ValidationError from zarr.core.group import normalize_path @@ -57,13 +55,14 @@ def _pad_shape(shape: tuple[int, ...], target: int = 5): def _open_store( - store_path: StrOrBytesPath, + store_path: StrOrBytesPath | Path, mode: Literal["r", "r+", "a", "w", "w-"], version: Literal["0.1", "0.4", "0.5"], ): - if not os.path.isdir(store_path) and mode in ("r", "r+"): + store_path = Path(store_path).resolve() + if not store_path.exists() and mode in ("r", "r+"): raise FileNotFoundError( - f"Dataset directory not found at {store_path}." + f"Dataset directory not found at {str(store_path)}." ) if version not in ("0.4", "0.5"): _logger.warning( @@ -71,13 +70,13 @@ def _open_store( f"Requested version {version} may not work properly." ) try: - store = zarr.storage.LocalStore(store_path) - root = zarr.open_group( - store, mode=mode, zarr_format=(3 if version == "0.5" else 2) - ) + zarr_format = None + if mode in ("w", "w-") or (mode == "a" and not store_path.exists()): + zarr_format = 3 if version == "0.5" else 2 + root = zarr.open_group(store_path, mode=mode, zarr_format=zarr_format) except Exception as e: raise RuntimeError( - f"Cannot open Zarr root group at {store_path}" + f"Cannot open Zarr root group at {str(store_path)}" ) from e return root @@ -141,6 +140,11 @@ def zattrs(self): Assignments will modify the metadata file.""" return self._group.attrs + @property + def maybe_wrapped_ome_attrs(self): + """Container of OME metadata attributes.""" + return self.zattrs.get("ome") or self.zattrs + @property def version(self): """NGFF version""" @@ -571,12 +575,12 @@ def _set_meta( ) example_image: ImageArray = self[ self.metadata.multiscales[0].datasets[0].path - ].channels + ] self._channel_names = list(range(example_image.channels)) def _parse_meta(self): - multiscales = self.zattrs.get("multiscales") - omero = self.zattrs.get("omero") + multiscales = self.maybe_wrapped_ome_attrs.get("multiscales") + omero = self.maybe_wrapped_ome_attrs.get("omero") if multiscales: try: self._set_meta(multiscales=multiscales, omero=omero) @@ -802,6 +806,7 @@ def _check_shape(self, data_shape: tuple[int]): ) def _create_compressor_options(self, chunk_shape: Tuple[int, ...] = None): + shuffle = zarr.codecs.BloscShuffle.bitshuffle if self._zarr_format == 3: return { "codecs": [ @@ -812,17 +817,19 @@ def _create_compressor_options(self, chunk_shape: Tuple[int, ...] = None): zarr.codecs.BloscCodec( cname="zstd", clevel=1, - shuffle=Blosc.BITSHUFFLE, + shuffle=shuffle, ), ], ) ], } else: + from numcodecs import Blosc + return { "compressor": Blosc( cname="zstd", clevel=1, shuffle=Blosc.BITSHUFFLE - ), + ) } def _create_image_meta( @@ -1379,7 +1386,7 @@ def __init__( ) def _parse_meta(self): - if well_group_meta := self.zattrs.get("well"): + if well_group_meta := self.maybe_wrapped_ome_attrs.get("well"): self.metadata = WellGroupMeta(**well_group_meta) else: self._warn_invalid_meta() @@ -1622,7 +1629,7 @@ def __init__( ) def _parse_meta(self): - if plate_meta := self.zattrs.get("plate"): + if plate_meta := self.maybe_wrapped_ome_attrs.get("plate"): _logger.debug(f"Loading HCS metadata from file: {plate_meta}") self.metadata = PlateMeta(**plate_meta) else: @@ -1930,6 +1937,49 @@ def rename_well(self, old: str, new: str): self.dump_meta() +def _check_file_mode( + store_path: Path, + mode: Literal["r", "r+", "a", "w", "w-"], + disable_path_checking: bool, +) -> bool: + if mode == "a": + mode = "r+" if store_path.exists() else "w-" + parse_meta = False + if mode in ("r", "r+"): + parse_meta = True + elif mode == "w-": + if store_path.exists(): + raise FileExistsError(store_path) + elif mode == "w": + if store_path.exists(): + if ( + ".zarr" not in str(store_path.resolve()) + and not disable_path_checking + ): + raise ValueError( + "Cannot overwrite a path that does not contain '.zarr', " + "use `disable_path_checking=True` if you are sure that " + f"{store_path} should be overwritten." + ) + _logger.warning(f"Overwriting data at {store_path}") + else: + raise ValueError(f"Invalid persistence mode '{mode}'.") + return parse_meta + + +def _detect_layout(meta_keys: list[str]) -> Literal["fov", "hcs"]: + if "plate" in meta_keys: + return "hcs" + elif "multiscales" in meta_keys: + return "fov" + else: + raise KeyError( + "Dataset metadata keys ('plate'/'multiscales') not in " + f"the found store metadata keys: {meta_keys}. " + "Is this a valid OME-Zarr dataset?" + ) + + def open_ome_zarr( store_path: StrOrBytesPath | Path, layout: Literal["auto", "fov", "hcs", "tiled"] = "auto", @@ -1937,7 +1987,6 @@ def open_ome_zarr( channel_names: list[str] | None = None, axes: list[AxisMeta] | None = None, version: Literal["0.1", "0.4", "0.5"] = "0.4", - synchronizer: zarr.ThreadSynchronizer | zarr.ProcessSynchronizer = None, disable_path_checking: bool = False, **kwargs, ) -> Plate | Position | TiledPosition: @@ -2003,42 +2052,17 @@ def open_ome_zarr( or :py:class:`iohub.ngff.TiledPosition`) """ store_path = Path(store_path) - if mode == "a": - mode = ("w-", "r+")[int(store_path.exists())] - parse_meta = False - if mode in ("r", "r+"): - parse_meta = True - elif mode == "w-": - if store_path.exists(): - raise FileExistsError(store_path) - elif mode == "w": - if store_path.exists(): - if ( - ".zarr" not in str(store_path.resolve()) - and not disable_path_checking - ): - raise ValueError( - "Cannot overwrite a path that does not contain '.zarr', " - "use `disable_path_checking=True` if you are sure that " - f"{store_path} should be overwritten." - ) - _logger.warning(f"Overwriting data at {store_path}") - else: - raise ValueError(f"Invalid persistence mode '{mode}'.") + parse_meta = _check_file_mode( + store_path, mode, disable_path_checking=disable_path_checking + ) root = _open_store(store_path, mode, version) meta_keys = root.attrs.keys() if parse_meta else [] + if "ome" in meta_keys: + meta_keys = root.attrs["ome"].keys() + version = root.attrs["ome"].get("version", version) if layout == "auto": if parse_meta: - if "plate" in meta_keys: - layout = "hcs" - elif "multiscales" in meta_keys: - layout = "fov" - else: - raise KeyError( - "Dataset metadata keys ('plate'/'multiscales') not in " - f"the found store metadata keys: {meta_keys}. " - "Is this a valid OME-Zarr dataset?" - ) + layout = _detect_layout(meta_keys) else: raise ValueError( "Store layout must be specified when creating a new dataset." @@ -2059,5 +2083,6 @@ def open_ome_zarr( parse_meta=parse_meta, channel_names=channel_names, axes=axes, + version=version, **kwargs, ) diff --git a/iohub/reader.py b/iohub/reader.py index d9d1b17e..dd02f201 100644 --- a/iohub/reader.py +++ b/iohub/reader.py @@ -24,7 +24,7 @@ def _find_ngff_version_in_zarr_group(group: zarr.Group) -> str | None: - for key in ["plate", "well"]: + for key in ["plate", "well", "ome"]: if key in group.attrs: if v := group.attrs[key].get("version"): return v @@ -200,8 +200,8 @@ def print_info(path: StrOrBytesPath, verbose=False): path = Path(path).resolve() try: fmt, extra_info = _infer_format(path) - if fmt == "omezarr" and extra_info == "0.4": - reader = open_ome_zarr(path, mode="r") + if fmt == "omezarr" and extra_info in ("0.4", "0.5"): + reader = open_ome_zarr(path, mode="r", version=extra_info) else: reader = read_images(path, data_type=fmt) except (ValueError, RuntimeError): diff --git a/setup.cfg b/setup.cfg index a930908b..15c8659b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,7 @@ install_requires = tifffile>=2024.1.30, <2025.5.21 natsort>=7.1.1 ndtiff>=2.2.1 - zarr>=3.0.0 + zarr>=3.0.8 rich tqdm pillow>=9.4.0 @@ -48,6 +48,7 @@ install_requires = [options.extras_require] dev = + acquire-zarr black flake8 pytest>=5.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index b3d4304b..f8fb8321 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,9 @@ import shutil from pathlib import Path +import acquire_zarr as aqz import fsspec +import numpy as np import pytest from wget import download @@ -135,3 +137,106 @@ def csv_data_file_2(tmpdir): writer = csv.writer(csvfile) writer.writerows(csv_data_2) return test_csv_2 + + +@pytest.fixture +def empty_ome_zarr_hcs_v05(tmpdir) -> tuple[Path, tuple[tuple[str, ...], ...]]: + """Create an empty HCS OME-Zarr v0.5 dataset.""" + example_json_dir = Path(__file__).parent / "ngff" / "static_data" / "v05" + empty_zarr = tmpdir / "v05.hcs.ome.zarr" + empty_zarr.mkdir() + TARGET_FILENAME = "zarr.json" + shutil.copy(example_json_dir / "plate.json", empty_zarr / TARGET_FILENAME) + ROWS = ("A", "B") + COLS = ("1", "2", "3") + FOVS = ("0", "1", "2", "3") + RESOLUTIONS = ("0", "1", "2") + for row in ROWS: + row_dir = empty_zarr / row + row_dir.mkdir() + shutil.copy(example_json_dir / "row.json", row_dir / TARGET_FILENAME) + for col in COLS: + col_dir = row_dir / col + col_dir.mkdir() + shutil.copy( + example_json_dir / "well.json", col_dir / TARGET_FILENAME + ) + for fov in FOVS: + fov_dir = col_dir / fov + fov_dir.mkdir() + shutil.copy( + example_json_dir / "image.json", fov_dir / TARGET_FILENAME + ) + for res in RESOLUTIONS: + res_dir = fov_dir / res + res_dir.mkdir() + shutil.copy( + example_json_dir / "array.json", + res_dir / TARGET_FILENAME, + ) + return empty_zarr, (ROWS, COLS, FOVS, RESOLUTIONS) + + +@pytest.fixture() +def aqz_ome_zarr_05(tmpdir): + store_path = tmpdir / "ome_zarr_v0.5.zarr" + + settings = aqz.StreamSettings( + compression=aqz.CompressionSettings( + codec=aqz.CompressionCodec.BLOSC_LZ4, + compressor=aqz.Compressor.BLOSC1, + level=1, + shuffle=0, + ), + data_type=aqz.DataType.UINT16, + dimensions=[ + aqz.Dimension( + name="t", + kind=aqz.DimensionType.TIME, + array_size_px=0, + chunk_size_px=16, + shard_size_chunks=1, + ), + aqz.Dimension( + name="c", + kind=aqz.DimensionType.CHANNEL, + array_size_px=4, + chunk_size_px=1, + shard_size_chunks=1, + ), + aqz.Dimension( + name="z", + kind=aqz.DimensionType.SPACE, + array_size_px=10, + chunk_size_px=10, + shard_size_chunks=1, + ), + aqz.Dimension( + name="y", + kind=aqz.DimensionType.SPACE, + array_size_px=48, + chunk_size_px=16, + shard_size_chunks=3, + ), + aqz.Dimension( + name="x", + kind=aqz.DimensionType.SPACE, + array_size_px=64, + chunk_size_px=16, + shard_size_chunks=2, + ), + ], + multiscale=True, + store_path=str(store_path), + version=aqz.ZarrVersion.V3, + max_threads=1, + ) + + stream = aqz.ZarrStream(settings) + data = np.random.randint( + 0, 2**16 - 1, (32, 4, 10, 48, 64), dtype=np.uint16 + ) + stream.append(data) + del stream + + return store_path diff --git a/tests/ngff/static_data/v05/array.json b/tests/ngff/static_data/v05/array.json new file mode 100644 index 00000000..e9181040 --- /dev/null +++ b/tests/ngff/static_data/v05/array.json @@ -0,0 +1,71 @@ +{ + "attributes": {}, + "chunk_grid": { + "configuration": { + "chunk_shape": [ + 10, + 16, + 32 + ] + }, + "name": "regular" + }, + "chunk_key_encoding": { + "configuration": { + "separator": "/" + }, + "name": "default" + }, + "codecs": [ + { + "configuration": { + "chunk_shape": [ + 5, + 16, + 16 + ], + "codecs": [ + { + "configuration": { + "endian": "little" + }, + "name": "bytes" + }, + { + "configuration": { + "blocksize": 0, + "clevel": 1, + "cname": "lz4", + "shuffle": "shuffle", + "typesize": 2 + }, + "name": "blosc" + } + ], + "index_codecs": [ + { + "configuration": { + "endian": "little" + }, + "name": "bytes" + }, + { + "name": "crc32c" + } + ], + "index_location": "end" + }, + "name": "sharding_indexed" + } + ], + "data_type": "uint16", + "fill_value": 0, + "node_type": "array", + "shape": [ + 50, + 48, + 64 + ], + "storage_transformers": [], + "zarr_format": 3 +} \ No newline at end of file diff --git a/tests/ngff/static_data/v05/image.json b/tests/ngff/static_data/v05/image.json new file mode 100644 index 00000000..d7ea5bcf --- /dev/null +++ b/tests/ngff/static_data/v05/image.json @@ -0,0 +1,134 @@ +{ + "zarr_format": 3, + "node_type": "group", + "attributes": { + "ome": { + "version": "0.5", + "multiscales": [ + { + "name": "example", + "axes": [ + { + "name": "t", + "type": "time", + "unit": "millisecond" + }, + { + "name": "c", + "type": "channel" + }, + { + "name": "z", + "type": "space", + "unit": "micrometer" + }, + { + "name": "y", + "type": "space", + "unit": "micrometer" + }, + { + "name": "x", + "type": "space", + "unit": "micrometer" + } + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 0.5, + 0.5, + 0.5 + ] + } + ] + }, + { + "path": "1", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ] + }, + { + "path": "2", + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 1.0, + 1.0, + 2.0, + 2.0, + 2.0 + ] + } + ] + } + ], + "coordinateTransformations": [ + { + "type": "scale", + "scale": [ + 0.1, + 1.0, + 1.0, + 1.0, + 1.0 + ] + } + ], + "type": "gaussian", + "metadata": { + "description": "the fields in metadata depend on the downscaling implementation. Here, the parameters passed to the skimage function are given", + "method": "skimage.transform.pyramid_gaussian", + "version": "0.16.1", + "args": "[true]", + "kwargs": { + "multichannel": true + } + } + } + ], + "omero": { + "id": 1, + "name": "example.zarr", + "channels": [ + { + "active": true, + "coefficient": 1, + "color": "0000FF", + "family": "linear", + "inverted": false, + "label": "LaminB1", + "window": { + "end": 1500, + "max": 65535, + "min": 0, + "start": 0 + } + } + ], + "rdefs": { + "defaultT": 0, + "defaultZ": 118, + "model": "color" + } + } + } + } +} \ No newline at end of file diff --git a/tests/ngff/static_data/v05/plate.json b/tests/ngff/static_data/v05/plate.json new file mode 100644 index 00000000..04ee2f01 --- /dev/null +++ b/tests/ngff/static_data/v05/plate.json @@ -0,0 +1,78 @@ +{ + "zarr_format": 3, + "node_type": "group", + "attributes": { + "ome": { + "version": "0.5", + "plate": { + "acquisitions": [ + { + "id": 1, + "maximumfieldcount": 2, + "name": "Meas_01(2012-07-31_10-41-12)", + "starttime": 1343731272000 + }, + { + "id": 2, + "maximumfieldcount": 2, + "name": "Meas_02(201207-31_11-56-41)", + "starttime": 1343735801000 + } + ], + "columns": [ + { + "name": "1" + }, + { + "name": "2" + }, + { + "name": "3" + } + ], + "field_count": 4, + "name": "test", + "rows": [ + { + "name": "A" + }, + { + "name": "B" + } + ], + "wells": [ + { + "path": "A/1", + "rowIndex": 0, + "columnIndex": 0 + }, + { + "path": "A/2", + "rowIndex": 0, + "columnIndex": 1 + }, + { + "path": "A/3", + "rowIndex": 0, + "columnIndex": 2 + }, + { + "path": "B/1", + "rowIndex": 1, + "columnIndex": 0 + }, + { + "path": "B/2", + "rowIndex": 1, + "columnIndex": 1 + }, + { + "path": "B/3", + "rowIndex": 1, + "columnIndex": 2 + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/ngff/static_data/v05/row.json b/tests/ngff/static_data/v05/row.json new file mode 100644 index 00000000..806ac8ad --- /dev/null +++ b/tests/ngff/static_data/v05/row.json @@ -0,0 +1,4 @@ +{ + "zarr_format": 3, + "node_type": "group" +} \ No newline at end of file diff --git a/tests/ngff/static_data/v05/well.json b/tests/ngff/static_data/v05/well.json new file mode 100644 index 00000000..fbb27781 --- /dev/null +++ b/tests/ngff/static_data/v05/well.json @@ -0,0 +1,29 @@ +{ + "zarr_format": 3, + "node_type": "group", + "attributes": { + "ome": { + "version": "0.5", + "well": { + "images": [ + { + "acquisition": 1, + "path": "0" + }, + { + "acquisition": 1, + "path": "1" + }, + { + "acquisition": 2, + "path": "2" + }, + { + "acquisition": 2, + "path": "3" + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/ngff/test_ngff.py b/tests/ngff/test_ngff.py index b3f6d996..2507546b 100644 --- a/tests/ngff/test_ngff.py +++ b/tests/ngff/test_ngff.py @@ -5,12 +5,14 @@ import shutil import string from contextlib import contextmanager +from itertools import product +from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING -from pathlib import Path import hypothesis.extra.numpy as npst import hypothesis.strategies as st +import numpy as np import pytest import zarr.storage from hypothesis import HealthCheck, assume, given, settings @@ -22,8 +24,8 @@ from iohub.ngff.nodes import ( TO_DICT_SETTINGS, - NGFFNode, Plate, + Position, TransformationMeta, _case_insensitive_local_fs, _open_store, @@ -80,11 +82,13 @@ def _random_array_shape_and_dtype_with_channels(draw, c_dim: int): draw(y_dim_st), draw(x_dim_st), ) + # zarr-python 3 broke big-endian support: + # https://github.com/zarr-developers/zarr-python/issues/3005 dtype = draw( st.one_of( - npst.integer_dtypes(), - npst.unsigned_integer_dtypes(), - npst.floating_dtypes(), + npst.integer_dtypes(endianness="<"), + npst.unsigned_integer_dtypes(endianness="<"), + npst.floating_dtypes(endianness="<"), npst.boolean_dtypes(), ) ) @@ -129,7 +133,7 @@ def test_open_store_create(): assert isinstance(root, zarr.Group) assert isinstance(root.store, zarr.storage.LocalStore) # assert root.store._dimension_separator == "/" - assert root.store.root == Path(store_path) + assert root.store.root.resolve() == Path(store_path).resolve() def test_open_store_create_existing(): @@ -208,7 +212,7 @@ def test_init_ome_zarr_overwrite_non_zarr(tmp_path, basename): @contextmanager -def _temp_ome_zarr( +def _temp_ome_zarr_v04( image_5d: NDArray, channel_names: list[str], arr_name: str, **kwargs ): """Helper function to generate a temporary OME-Zarr store. @@ -229,6 +233,7 @@ def _temp_ome_zarr( os.path.join(temp_dir.name, "ome.zarr"), layout="fov", mode="a", + version="0.4", channel_names=channel_names, ) dataset.create_image(arr_name, image_5d, **kwargs) @@ -294,7 +299,7 @@ def test_write_ome_zarr(channels_and_random_5d, arr_name): from ome_zarr.reader import Reader channel_names, random_5d = channels_and_random_5d - with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, arr_name) as dataset: assert_array_almost_equal(dataset[arr_name][:], random_5d) # round-trip test with the offical reader implementation ext_reader = Reader(parse_url(dataset.zgroup.store.path)) @@ -343,11 +348,11 @@ def test_create_zeros(ch_shape_dtype, arr_name): def test_ome_zarr_to_dask(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.data` to dask""" channel_names, random_5d = channels_and_random_5d - with _temp_ome_zarr(random_5d, channel_names, "0") as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, "0") as dataset: assert_array_almost_equal( dataset.data.dask_array().compute(), random_5d ) - with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, arr_name) as dataset: assert_array_almost_equal( dataset[arr_name].dask_array().compute(), random_5d ) @@ -366,10 +371,10 @@ def test_position_data(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.data`""" channel_names, random_5d = channels_and_random_5d assume(arr_name != "0") - with _temp_ome_zarr(random_5d, channel_names, "0") as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, "0") as dataset: assert_array_almost_equal(dataset.data.numpy(), random_5d) with pytest.raises(KeyError): - with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, arr_name) as dataset: _ = dataset.data @@ -386,7 +391,7 @@ def test_append_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.append_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr( + with _temp_ome_zarr_v04( random_5d[:, :-1], channel_names[:-1], arr_name ) as dataset: dataset.append_channel(channel_names[-1], resize_arrays=True) @@ -408,12 +413,13 @@ def test_rename_channel(channels_and_random_5d, arr_name, new_channel): """Test `iohub.ngff.Position.rename_channel()`""" channel_names, random_5d = channels_and_random_5d assume(new_channel not in channel_names) - with _temp_ome_zarr(random_5d, channel_names, arr_name) as dataset: + with _temp_ome_zarr_v04(random_5d, channel_names, arr_name) as dataset: dataset.rename_channel(old=channel_names[0], new=new_channel) assert new_channel in dataset.channel_names assert dataset.metadata.omero.channels[0].label == new_channel +# @pytest.mark.skip(reason="broken") @given( channels_and_random_5d=_channels_and_random_5d(), arr_name=short_alpha_numeric, @@ -481,7 +487,7 @@ def test_update_channel(channels_and_random_5d, arr_name): """Test `iohub.ngff.Position.update_channel()`""" channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) - with _temp_ome_zarr( + with _temp_ome_zarr_v04( random_5d[:, :-1], channel_names[:-1], arr_name ) as dataset: for i, ch in enumerate(dataset.channel_names): @@ -507,7 +513,7 @@ def test_write_more_channels(channels_and_random_5d, arr_name): channel_names, random_5d = channels_and_random_5d assume(len(channel_names) > 1) with pytest.raises(ValueError): - with _temp_ome_zarr(random_5d, channel_names[:-1], arr_name) as _: + with _temp_ome_zarr_v04(random_5d, channel_names[:-1], arr_name) as _: pass @@ -892,11 +898,11 @@ def test_create_hcs(channel_names): def test_open_hcs_create_empty(): """Test `iohub.ngff.open_ome_zarr()`""" with TemporaryDirectory() as temp_dir: - store_path = os.path.join(temp_dir, "hcs.zarr") + store_path = Path(temp_dir) / "hcs.zarr" dataset = open_ome_zarr( store_path, layout="hcs", mode="a", channel_names=["GFP"] ) - assert str(dataset.zgroup.store.root) == store_path + assert dataset.zgroup.store.root.resolve() == store_path.resolve() dataset.close() with pytest.raises(FileExistsError): _ = open_ome_zarr( @@ -1054,7 +1060,7 @@ def test_position_scale(channels_and_random_5d): channel_names, random_5d = channels_and_random_5d scale = list(range(1, 6)) transform = [TransformationMeta(type="scale", scale=scale)] - with _temp_ome_zarr( + with _temp_ome_zarr_v04( random_5d, channel_names, "0", transform=transform ) as dataset: assert dataset.scale == scale @@ -1064,6 +1070,9 @@ def test_position_scale(channels_and_random_5d): reason="https://github.com/zarr-developers/zarr-python/issues/2407" ) def test_combine_fovs_to_hcs(): + from ome_zarr.io import parse_url + from ome_zarr.reader import Reader + fovs = {} fov_paths = ("A/1/0", "B/1/0", "H/12/9") with open_ome_zarr(hcs_ref) as hcs_store: @@ -1106,3 +1115,43 @@ def test_hcs_external_reader(tmp_path): assert plate.data[0].dtype == int assert not plate.data[0].any() assert plate.metadata["channel_names"] == ["A", "B"] + + +def test_read_empty_hcs_v05(empty_ome_zarr_hcs_v05): + """Test reading an empty OME-Zarr v0.5 HCS store.""" + empty_zarr, (rows, cols, fovs, resolutions) = empty_ome_zarr_hcs_v05 + with open_ome_zarr(empty_zarr, layout="hcs", mode="r") as dataset: + for row, col, fov in product(rows, cols, fovs): + position: Position = dataset[f"{row}/{col}/{fov}"] + for resolution in resolutions: + assert_array_equal( + position[resolution].numpy(), + np.zeros((50, 48, 64), dtype=np.uint16), + ) + assert len(list(dataset.positions())) == len(rows) * len(cols) * len( + fovs + ) + + +def test_acquire_zarr_ome_zarr_05(aqz_ome_zarr_05): + """Test that `iohub.ngff.open_ome_zarr()` can read OME-Zarr 0.5.""" + with open_ome_zarr( + aqz_ome_zarr_05, layout="fov", mode="r", version="0.5" + ) as dataset: + assert dataset.version == "0.5" + assert dataset.data.shape == (32, 4, 10, 48, 64) + assert dataset.data.chunks == (16, 1, 10, 16, 16) + assert dataset.data.shards == (16, 1, 10, 48, 32) + assert "ome" in dataset.zattrs + assert "multiscales" in dataset.zattrs["ome"] + assert len(dataset.zattrs["ome"]["multiscales"]) == 1 + + multiscale = dataset.zattrs["ome"]["multiscales"][0] + assert len(multiscale["datasets"]) == 2 + assert multiscale["datasets"][0]["coordinateTransformations"][0][ + "scale" + ] == [1.0, 1.0, 1.0, 1.0, 1.0] + assert multiscale["datasets"][1]["coordinateTransformations"][0][ + "scale" + ] == [1.0, 1.0, 2.0, 2.0, 2.0] + assert 1 < dataset["0"].numpy().mean() < np.iinfo(np.uint16).max