From a46dfa8b33e352bcb4da90553da01da5aa3e64c2 Mon Sep 17 00:00:00 2001 From: Adam Gohain Date: Sat, 8 Aug 2026 09:15:08 -0400 Subject: [PATCH 1/2] Add multiscale volume pyramid contract --- server_api/workflows/volume_io.py | 344 +++++++++++++++++++++++++++++- tests/test_volume_io.py | 332 ++++++++++++++++++++++++++++ 2 files changed, 666 insertions(+), 10 deletions(-) diff --git a/server_api/workflows/volume_io.py b/server_api/workflows/volume_io.py index 79fdd1f6..b6d8ea74 100644 --- a/server_api/workflows/volume_io.py +++ b/server_api/workflows/volume_io.py @@ -36,6 +36,28 @@ ) +@dataclass(frozen=True) +class VolumeAxis: + """A named array axis in canonical multiscale order.""" + + name: str + type: Optional[str] = None + unit: Optional[str] = None + + +@dataclass(frozen=True) +class VolumeLevel: + """Storage and coordinate metadata for one pyramid level.""" + + index: int + dataset_key: Optional[str] + shape: Tuple[int, ...] + dtype: np.dtype + chunks: Optional[Tuple[int, ...]] = None + scale: Tuple[float, ...] = () + translation: Tuple[float, ...] = () + + @dataclass(frozen=True) class VolumeMetadata: """Storage-level metadata available without materializing voxel data.""" @@ -46,6 +68,10 @@ class VolumeMetadata: dtype: np.dtype dataset_key: Optional[str] = None chunks: Optional[Tuple[int, ...]] = None + axes: Tuple[VolumeAxis, ...] = () + levels: Tuple[VolumeLevel, ...] = () + selected_level: int = 0 + multiscale_version: Optional[str] = None @property def ndim(self) -> int: @@ -104,22 +130,44 @@ def __init__( format: str, dataset_key: Optional[str] = None, close: Optional[Callable[[], None]] = None, + axes: Sequence[VolumeAxis] = (), + levels: Sequence[VolumeLevel] = (), + selected_level: int = 0, + multiscale_version: Optional[str] = None, ) -> None: self._data = data self._close = close self._closed = False chunks = getattr(data, "chunks", None) + shape = tuple(int(value) for value in data.shape) + dtype = np.dtype(data.dtype) + normalized_chunks = ( + tuple(int(value) for value in chunks) + if chunks is not None and all(value is not None for value in chunks) + else None + ) + normalized_levels = tuple(levels) or ( + VolumeLevel( + index=0, + dataset_key=dataset_key, + shape=shape, + dtype=dtype, + chunks=normalized_chunks, + scale=tuple(1.0 for _ in shape), + translation=tuple(0.0 for _ in shape), + ), + ) self._metadata = VolumeMetadata( path=str(path), format=format, - shape=tuple(int(value) for value in data.shape), - dtype=np.dtype(data.dtype), + shape=shape, + dtype=dtype, dataset_key=dataset_key, - chunks=( - tuple(int(value) for value in chunks) - if chunks is not None and all(value is not None for value in chunks) - else None - ), + chunks=normalized_chunks, + axes=tuple(axes), + levels=normalized_levels, + selected_level=selected_level, + multiscale_version=multiscale_version, ) @property @@ -389,6 +437,182 @@ def _select_zarr_array(store: Any, dataset_key: Optional[str]) -> Any: return store[arrays[0]] +def _normalized_chunks(data: Any) -> Optional[Tuple[int, ...]]: + chunks = getattr(data, "chunks", None) + if chunks is None or not all(value is not None for value in chunks): + return None + return tuple(int(value) for value in chunks) + + +def _zarr_attrs(value: Any) -> dict: + attrs = getattr(value, "attrs", None) + if attrs is None: + return {} + try: + return dict(attrs) + except Exception: + asdict = getattr(attrs, "asdict", None) + return dict(asdict()) if callable(asdict) else {} + + +def _ngff_multiscales(group: Any) -> Tuple[Optional[List[Any]], Optional[str]]: + """Return NGFF multiscales for both 0.4 and 0.5 metadata layouts.""" + + attrs = _zarr_attrs(group) + multiscales = attrs.get("multiscales") + version: Optional[str] = None + if multiscales is None: + ome = attrs.get("ome") + if isinstance(ome, dict): + multiscales = ome.get("multiscales") + if ome.get("version") is not None: + version = str(ome["version"]) + if multiscales is None: + return None, version + if not isinstance(multiscales, list) or not multiscales: + raise ValueError("NGFF multiscales metadata must be a non-empty list") + return multiscales, version + + +def _parse_ngff_axes(raw_axes: Any) -> Tuple[VolumeAxis, ...]: + if raw_axes is None: + return () + if not isinstance(raw_axes, list): + raise ValueError("NGFF axes metadata must be a list") + axes: List[VolumeAxis] = [] + for axis in raw_axes: + if isinstance(axis, str): + axes.append(VolumeAxis(name=axis)) + continue + if not isinstance(axis, dict) or not axis.get("name"): + raise ValueError("Each NGFF axis must be a name or an object with a name") + axes.append( + VolumeAxis( + name=str(axis["name"]), + type=str(axis["type"]) if axis.get("type") is not None else None, + unit=str(axis["unit"]) if axis.get("unit") is not None else None, + ) + ) + return tuple(axes) + + +def _parse_ngff_transform( + transforms: Any, ndim: int, *, dataset_path: str +) -> Tuple[Tuple[float, ...], Tuple[float, ...]]: + scale = [1.0] * ndim + translation = [0.0] * ndim + if transforms is None: + transforms = [] + if not isinstance(transforms, list): + raise ValueError( + f"NGFF coordinateTransformations for {dataset_path!r} must be a list" + ) + for transform in transforms: + if not isinstance(transform, dict): + raise ValueError(f"Invalid NGFF transform for {dataset_path!r}") + transform_type = transform.get("type") + if transform_type not in {"scale", "translation"}: + raise ValueError( + f"Unsupported NGFF transform {transform_type!r} for {dataset_path!r}" + ) + values = transform.get(transform_type) + if not isinstance(values, (list, tuple)) or len(values) != ndim: + raise ValueError( + f"NGFF {transform_type} for {dataset_path!r} must have {ndim} values" + ) + vector = [float(value) for value in values] + if transform_type == "scale": + scale = [current * value for current, value in zip(scale, vector)] + translation = [ + current * value for current, value in zip(translation, vector) + ] + else: + translation = [ + current + value for current, value in zip(translation, vector) + ] + return tuple(scale), tuple(translation) + + +def _open_ngff_level( + group: Any, *, level: Optional[int] +) -> Optional[ + Tuple[Any, Tuple[VolumeAxis, ...], Tuple[VolumeLevel, ...], int, Optional[str]] +]: + multiscales, container_version = _ngff_multiscales(group) + if multiscales is None: + return None + + multiscale = multiscales[0] + if not isinstance(multiscale, dict): + raise ValueError("NGFF multiscales entries must be objects") + datasets = multiscale.get("datasets") + if not isinstance(datasets, list) or not datasets: + raise ValueError("NGFF multiscale datasets must be a non-empty list") + axes = _parse_ngff_axes(multiscale.get("axes")) + selected_level = 0 if level is None else level + if isinstance(selected_level, bool) or not isinstance(selected_level, int): + raise ValueError("Pyramid level must be an integer") + if selected_level < 0 or selected_level >= len(datasets): + raise ValueError( + f"Pyramid level {selected_level} is out of range; " + f"available levels are 0..{len(datasets) - 1}" + ) + + parsed_levels: List[VolumeLevel] = [] + arrays: List[Any] = [] + for index, dataset in enumerate(datasets): + if not isinstance(dataset, dict) or not dataset.get("path"): + raise ValueError("Each NGFF dataset must provide a non-empty path") + dataset_path = str(dataset["path"]) + try: + data = group[dataset_path] + except Exception as exc: + raise ValueError(f"NGFF dataset {dataset_path!r} was not found") from exc + if not _is_zarr_array(data): + raise ValueError(f"NGFF dataset {dataset_path!r} is not an array") + shape = tuple(int(value) for value in data.shape) + if axes and len(axes) != len(shape): + raise ValueError( + f"NGFF axes has {len(axes)} entries but {dataset_path!r} is {len(shape)}D" + ) + dataset_transforms = dataset.get("coordinateTransformations") or [] + multiscale_transforms = multiscale.get("coordinateTransformations") or [] + if not isinstance(dataset_transforms, list) or not isinstance( + multiscale_transforms, list + ): + raise ValueError("NGFF coordinateTransformations must be lists") + scale, translation = _parse_ngff_transform( + dataset_transforms + multiscale_transforms, + len(shape), + dataset_path=dataset_path, + ) + arrays.append(data) + parsed_levels.append( + VolumeLevel( + index=index, + dataset_key=getattr(data, "path", None) or dataset_path, + shape=shape, + dtype=np.dtype(data.dtype), + chunks=_normalized_chunks(data), + scale=scale, + translation=translation, + ) + ) + version = multiscale.get("version") or container_version + return ( + arrays[selected_level], + axes, + tuple(parsed_levels), + selected_level, + str(version) if version is not None else None, + ) + + +def _validate_single_level(level: Optional[int]) -> None: + if level not in (None, 0): + raise ValueError("This artifact has only pyramid level 0") + + def _close_all(*resources: Any) -> Callable[[], None]: def close() -> None: first_error: Optional[Exception] = None @@ -427,6 +651,7 @@ def open_volume_store( path: str, *, dataset_key: Optional[str] = None, + level: Optional[int] = None, ) -> VolumeStore: """Open a volume for metadata inspection and bounded region reads. @@ -446,6 +671,7 @@ def open_volume_store( lower_path = str(target).lower() if lower_name.endswith((".h5", ".hdf5", ".hdf")): + _validate_single_level(level) import h5py handle = h5py.File(target, "r") @@ -469,16 +695,75 @@ def open_volume_store( handle = tifffile.TiffFile(str(target)) try: - tiff_store = handle.series[0].aszarr() + series = handle.series[0] + pyramid = tuple(getattr(series, "levels", ()) or (series,)) + selected_level = 0 if level is None else level + if ( + isinstance(selected_level, bool) + or not isinstance(selected_level, int) + or selected_level < 0 + or selected_level >= len(pyramid) + ): + raise ValueError( + f"Pyramid level {selected_level} is out of range; " + f"available levels are 0..{len(pyramid) - 1}" + ) + selected_series = pyramid[selected_level] + try: + # Opening through the base series with an explicit level avoids + # receiving a multiscale Zarr group for level 0. + tiff_store = series.aszarr(level=selected_level) + except TypeError: # pragma: no cover - older tifffile compatibility + tiff_store = selected_series.aszarr() data = zarr.open(tiff_store, mode="r") + if not _is_zarr_array(data): + try: + data = data[str(selected_level)] + except Exception: + data = _select_zarr_array(data, None) + base_shape = tuple(int(value) for value in pyramid[0].shape) + levels: List[VolumeLevel] = [] + for index, pyramid_series in enumerate(pyramid): + shape = tuple(int(value) for value in pyramid_series.shape) + scale = tuple( + float(base) / float(current) + for base, current in zip(base_shape, shape) + ) + levels.append( + VolumeLevel( + index=index, + dataset_key=None if index == 0 else f"level/{index}", + shape=shape, + dtype=np.dtype(pyramid_series.dtype), + chunks=( + _normalized_chunks(data) + if index == selected_level + else None + ), + scale=scale, + translation=tuple(0.0 for _ in shape), + ) + ) + axis_names = str(getattr(series, "axes", "")) + axes = tuple(VolumeAxis(name=name.lower()) for name in axis_names) return ArrayVolumeStore( data, path=target, format="ome-tiff" if ".ome.tif" in lower_name else "tiff", + dataset_key=( + None if selected_level == 0 else f"level/{selected_level}" + ), close=_close_all(tiff_store, handle), + axes=axes, + levels=levels, + selected_level=selected_level, ) + except ValueError: + handle.close() + raise except Exception: handle.close() + _validate_single_level(level) return ArrayVolumeStore( tifffile.imread(str(target)), path=target, @@ -486,6 +771,7 @@ def open_volume_store( ) if lower_name.endswith(".npy"): + _validate_single_level(level) data = np.load(target, mmap_mode="r") mmap = getattr(data, "_mmap", None) return ArrayVolumeStore( @@ -496,6 +782,7 @@ def open_volume_store( ) if lower_name.endswith(".npz"): + _validate_single_level(level) loaded = np.load(target) try: selected_key, data = _select_npz_array(loaded, dataset_key) @@ -514,7 +801,40 @@ def open_volume_store( import zarr root = zarr.open(str(target), mode="r") - data = _select_zarr_array(root, dataset_key) + candidate = root + if dataset_key and not _is_zarr_array(root): + try: + candidate = root[dataset_key] + except Exception as exc: + raise ValueError(f"Zarr/N5 path {dataset_key!r} not found") from exc + + ngff = ( + None + if _is_zarr_array(candidate) + else _open_ngff_level(candidate, level=level) + ) + if ngff is not None: + data, axes, levels, selected_level, version = ngff + selected_key = ( + getattr(data, "path", None) or levels[selected_level].dataset_key + ) + return ArrayVolumeStore( + data, + path=target, + format="n5" if lower_name.endswith(".n5") else "zarr", + dataset_key=selected_key, + axes=axes, + levels=levels, + selected_level=selected_level, + multiscale_version=version, + ) + + _validate_single_level(level) + data = ( + candidate + if _is_zarr_array(candidate) + else _select_zarr_array(candidate, None) + ) selected_key = dataset_key or getattr(data, "path", None) or None return ArrayVolumeStore( data, @@ -524,6 +844,7 @@ def open_volume_store( ) if lower_path.endswith((".nii", ".nii.gz")): + _validate_single_level(level) try: import nibabel as nib except Exception as exc: # pragma: no cover - optional dependency @@ -537,6 +858,7 @@ def open_volume_store( ) if lower_name.endswith((".mrc", ".map", ".rec")): + _validate_single_level(level) try: import mrcfile except Exception as exc: # pragma: no cover - optional dependency @@ -550,6 +872,7 @@ def open_volume_store( ) if lower_name.endswith((".png", ".jpg", ".jpeg", ".bmp")): + _validate_single_level(level) import imageio.v3 as iio return ArrayVolumeStore( @@ -568,12 +891,13 @@ def load_volume( path: str, *, dataset_key: Optional[str] = None, + level: Optional[int] = None, crop: CropSpec = None, channel: Optional[int] = None, reference_ndim: Optional[int] = None, label: str = "volume", ) -> np.ndarray: - with open_volume_store(path, dataset_key=dataset_key) as store: + with open_volume_store(path, dataset_key=dataset_key, level=level) as store: return store.read( crop, channel=channel, diff --git a/tests/test_volume_io.py b/tests/test_volume_io.py index b8dd6dc4..ec0e5895 100644 --- a/tests/test_volume_io.py +++ b/tests/test_volume_io.py @@ -12,6 +12,50 @@ ) +def _write_ngff_pyramid(path): + zarr = pytest.importorskip("zarr") + fine = np.arange(8 * 12 * 16, dtype=np.uint16).reshape(8, 12, 16) + coarse = (10000 + np.arange(4 * 6 * 8, dtype=np.uint16)).reshape(4, 6, 8) + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("fine", data=fine, chunks=(2, 4, 4)) + create_array("coarse", data=coarse, chunks=(1, 3, 4)) + root.attrs["multiscales"] = [ + { + "version": "0.4", + "name": "synthetic-image", + "axes": [ + {"name": "z", "type": "space", "unit": "nanometer"}, + {"name": "y", "type": "space", "unit": "nanometer"}, + {"name": "x", "type": "space", "unit": "nanometer"}, + ], + "datasets": [ + { + "path": "fine", + "coordinateTransformations": [ + {"type": "scale", "scale": [40.0, 8.0, 8.0]}, + { + "type": "translation", + "translation": [20.0, 4.0, 4.0], + }, + ], + }, + { + "path": "coarse", + "coordinateTransformations": [ + {"type": "scale", "scale": [80.0, 16.0, 16.0]}, + { + "type": "translation", + "translation": [40.0, 8.0, 8.0], + }, + ], + }, + ], + } + ] + return fine, coarse + + def test_parse_crop_accepts_voxel_slice_strings(): assert parse_crop("0:4,10:20,30:40") == ( slice(0, 4, None), @@ -203,3 +247,291 @@ def test_open_volume_store_reads_zarr_region(tmp_path): assert store.metadata.chunks == (2, 4, 6) np.testing.assert_array_equal(loaded, volume[1:4, 3:8, 5:11]) + + +def test_open_volume_store_reads_ngff_axes_transforms_and_levels(tmp_path): + path = tmp_path / "pyramid.zarr" + fine, _coarse = _write_ngff_pyramid(path) + + with open_volume_store(str(path)) as store: + metadata = store.metadata + + assert metadata.multiscale_version == "0.4" + assert metadata.selected_level == 0 + assert metadata.dataset_key == "fine" + assert metadata.shape == fine.shape + assert metadata.chunks == (2, 4, 4) + assert tuple(axis.name for axis in metadata.axes) == ("z", "y", "x") + assert tuple(axis.type for axis in metadata.axes) == ( + "space", + "space", + "space", + ) + assert tuple(axis.unit for axis in metadata.axes) == ( + "nanometer", + "nanometer", + "nanometer", + ) + assert len(metadata.levels) == 2 + assert metadata.levels[0].index == 0 + assert metadata.levels[0].dataset_key == "fine" + assert metadata.levels[0].scale == (40.0, 8.0, 8.0) + assert metadata.levels[0].translation == (20.0, 4.0, 4.0) + assert metadata.levels[1].index == 1 + assert metadata.levels[1].dataset_key == "coarse" + assert metadata.levels[1].shape == (4, 6, 8) + assert metadata.levels[1].chunks == (1, 3, 4) + assert metadata.levels[1].scale == (80.0, 16.0, 16.0) + assert metadata.levels[1].translation == (40.0, 8.0, 8.0) + + +def test_open_volume_store_selects_explicit_ngff_level_and_reads_bounded_region( + tmp_path, +): + path = tmp_path / "pyramid.zarr" + _fine, coarse = _write_ngff_pyramid(path) + + with open_volume_store(str(path), level=1) as store: + loaded = store.read("1:3,2:5,3:7") + + assert store.metadata.selected_level == 1 + assert store.metadata.dataset_key == "coarse" + assert store.metadata.shape == coarse.shape + + np.testing.assert_array_equal(loaded, coarse[1:3, 2:5, 3:7]) + + +def test_load_volume_selects_explicit_ngff_level(tmp_path): + path = tmp_path / "pyramid.zarr" + _fine, coarse = _write_ngff_pyramid(path) + + loaded = load_volume(str(path), level=1, crop="0:2,1:4,2:6") + + np.testing.assert_array_equal(loaded, coarse[0:2, 1:4, 2:6]) + + +@pytest.mark.parametrize("level", [-1, 2]) +def test_open_volume_store_rejects_invalid_ngff_level(tmp_path, level): + path = tmp_path / "pyramid.zarr" + _write_ngff_pyramid(path) + + with pytest.raises(ValueError, match="level"): + open_volume_store(str(path), level=level) + + +def test_open_volume_store_rejects_malformed_ngff_transform_dimensions(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "malformed.zarr" + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("0", shape=(4, 6, 8), chunks=(2, 3, 4), dtype=np.uint8) + root.attrs["multiscales"] = [ + { + "version": "0.4", + "axes": [ + {"name": "z", "type": "space"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + {"type": "scale", "scale": [2.0, 2.0]} + ], + } + ], + } + ] + + with pytest.raises(ValueError, match="scale"): + open_volume_store(str(path)) + + +def test_open_volume_store_rejects_ngff_dataset_that_does_not_exist(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "missing-level.zarr" + root = zarr.open_group(str(path), mode="w") + root.attrs["multiscales"] = [ + { + "version": "0.4", + "axes": [ + {"name": "z", "type": "space"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], + "datasets": [{"path": "does-not-exist"}], + } + ] + + with pytest.raises(ValueError, match="does-not-exist"): + open_volume_store(str(path)) + + +def test_bare_zarr_remains_a_single_level_volume(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "bare.zarr" + volume = np.arange(4 * 6 * 8, dtype=np.uint16).reshape(4, 6, 8) + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("raw", data=volume, chunks=(2, 3, 4)) + + with open_volume_store(str(path)) as store: + loaded = store.read("1:3,2:5,3:7") + assert store.metadata.dataset_key == "raw" + assert store.metadata.selected_level == 0 + assert store.metadata.axes == () + assert len(store.metadata.levels) == 1 + assert store.metadata.levels[0].dataset_key == "raw" + + np.testing.assert_array_equal(loaded, volume[1:3, 2:5, 3:7]) + + with pytest.raises(ValueError, match="level"): + open_volume_store(str(path), level=1) + + +def test_open_volume_store_reads_nested_ngff_05_ome_metadata(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "ngff-05.zarr" + fine = np.arange(6 * 10 * 14, dtype=np.uint16).reshape(6, 10, 14) + coarse = fine[::2, ::2, ::2] + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("scale0", data=fine, chunks=(2, 5, 7)) + create_array("scale1", data=coarse, chunks=(1, 5, 7)) + root.attrs["ome"] = { + "version": "0.5", + "multiscales": [ + { + "name": "nested-metadata", + "axes": [ + {"name": "z", "type": "space", "unit": "micrometer"}, + {"name": "y", "type": "space", "unit": "micrometer"}, + {"name": "x", "type": "space", "unit": "micrometer"}, + ], + "datasets": [ + { + "path": "scale0", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0, 0.5, 0.5]} + ], + }, + { + "path": "scale1", + "coordinateTransformations": [ + {"type": "scale", "scale": [2.0, 1.0, 1.0]} + ], + }, + ], + } + ], + } + + with open_volume_store(str(path), level=1) as store: + assert store.metadata.multiscale_version == "0.5" + assert store.metadata.selected_level == 1 + assert store.metadata.dataset_key == "scale1" + assert store.metadata.shape == coarse.shape + assert tuple(axis.name for axis in store.metadata.axes) == ("z", "y", "x") + assert tuple(axis.unit for axis in store.metadata.axes) == ( + "micrometer", + "micrometer", + "micrometer", + ) + assert store.metadata.levels[1].scale == (2.0, 1.0, 1.0) + loaded = store.read("1:3,1:4,2:6") + + np.testing.assert_array_equal(loaded, coarse[1:3, 1:4, 2:6]) + + +def test_ngff_composes_dataset_then_multiscale_transforms(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "composed-transforms.zarr" + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("0", shape=(4, 6, 8), chunks=(2, 3, 4), dtype=np.uint8) + root.attrs["multiscales"] = [ + { + "version": "0.4", + "axes": ["z", "y", "x"], + "coordinateTransformations": [ + {"type": "scale", "scale": [10.0, 20.0, 30.0]}, + {"type": "translation", "translation": [5.0, 6.0, 7.0]}, + ], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [ + {"type": "scale", "scale": [2.0, 3.0, 4.0]}, + {"type": "translation", "translation": [1.0, 2.0, 3.0]}, + ], + } + ], + } + ] + + with open_volume_store(str(path)) as store: + level = store.metadata.levels[0] + + # Dataset coordinates are transformed first, then mapped through the + # multiscale coordinate system: parent_scale * child_translation + parent_t. + assert level.scale == (20.0, 60.0, 120.0) + assert level.translation == (15.0, 46.0, 97.0) + + +def test_open_volume_store_rejects_unsupported_ngff_dataset_transform(tmp_path): + zarr = pytest.importorskip("zarr") + path = tmp_path / "unsupported-transform.zarr" + root = zarr.open_group(str(path), mode="w") + create_array = getattr(root, "create_array", root.create_dataset) + create_array("0", shape=(4, 6, 8), chunks=(2, 3, 4), dtype=np.uint8) + root.attrs["multiscales"] = [ + { + "version": "0.4", + "axes": ["z", "y", "x"], + "datasets": [ + { + "path": "0", + "coordinateTransformations": [{"type": "rotation", "angle": 45.0}], + } + ], + } + ] + + with pytest.raises(ValueError, match="[Uu]nsupported.*transform"): + open_volume_store(str(path)) + + +def test_open_volume_store_selects_ome_tiff_subifd_level(tmp_path): + tifffile = pytest.importorskip("tifffile") + path = tmp_path / "pyramid.ome.tif" + fine = np.arange(32 * 48, dtype=np.uint16).reshape(32, 48) + coarse = fine[::2, ::2] + with tifffile.TiffWriter(path, bigtiff=True) as handle: + handle.write( + fine, + subifds=1, + metadata={"axes": "YX"}, + photometric="minisblack", + ) + handle.write( + coarse, + subfiletype=1, + metadata={"axes": "YX"}, + photometric="minisblack", + ) + + with open_volume_store(str(path)) as base_store: + assert base_store.metadata.selected_level == 0 + assert base_store.metadata.shape == fine.shape + assert tuple(axis.name for axis in base_store.metadata.axes) == ("y", "x") + assert len(base_store.metadata.levels) == 2 + np.testing.assert_array_equal(base_store.read("3:8,4:11"), fine[3:8, 4:11]) + + with open_volume_store(str(path), level=1) as coarse_store: + assert coarse_store.metadata.selected_level == 1 + assert coarse_store.metadata.shape == coarse.shape + assert coarse_store.metadata.levels[1].scale == (2.0, 2.0) + loaded = coarse_store.read("2:6,3:9") + + np.testing.assert_array_equal(loaded, coarse[2:6, 3:9]) From db24d651fd2a957074f1a6a2ec9fa74eecb2de8d Mon Sep 17 00:00:00 2001 From: Adam Gohain Date: Sat, 8 Aug 2026 09:23:07 -0400 Subject: [PATCH 2/2] Run CI for stacked pull requests --- .github/workflows/ci.yml | 1 + .github/workflows/superlinter.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 569e86a3..9f2f0ad7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ permissions: pull_request: branches: - main + - "agent/**" concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/superlinter.yml b/.github/workflows/superlinter.yml index e91ddcf5..568ba042 100644 --- a/.github/workflows/superlinter.yml +++ b/.github/workflows/superlinter.yml @@ -12,6 +12,7 @@ permissions: pull_request: branches: - main + - "agent/**" jobs: super-lint: