diff --git a/docs/guide/ome_zarr_napari.md b/docs/guide/ome_zarr_napari.md index 76ed61b..1291c92 100644 --- a/docs/guide/ome_zarr_napari.md +++ b/docs/guide/ome_zarr_napari.md @@ -50,12 +50,16 @@ to_ome_zarr("scan.czi", "scan.zarr", n_levels=5) # via bioio to_ome_zarr("scan.ims", "scan.zarr") # Imaris, native HDF5 ``` -!!! note "Imaris pyramids are rebuilt, not reused" - `.ims` files carry their own resolution pyramid, but `to_ome_zarr` reads - only the **full-resolution** level and **builds a fresh NGFF pyramid** from - it. This guarantees a consistent pyramid (XY-only, nearest-neighbour, - calibrated) rather than inheriting Imaris's own downsampling scheme. It - costs some extra compute, but the build is lazy and OOM-safe. +!!! note "Imaris pyramids: rebuild (default) or reuse" + `.ims` files carry their own resolution pyramid. By default `to_ome_zarr` + reads only the **full-resolution** level and **builds a fresh NGFF pyramid** + (XY-only, nearest-neighbour, calibrated) for consistency. Pass + `reuse_pyramid=True` to instead **copy the Imaris levels** as-is — faster, + no recompute, keeping each level's native scale: + + ```python + to_ome_zarr("scan.ims", "scan.zarr", reuse_pyramid=True) + ``` ### Pixel calibration diff --git a/src/patchworks/plugins/ome_zarr.py b/src/patchworks/plugins/ome_zarr.py index cedfc8f..bff58a2 100644 --- a/src/patchworks/plugins/ome_zarr.py +++ b/src/patchworks/plugins/ome_zarr.py @@ -255,8 +255,8 @@ def _open_bioio(path: str, scene: int) -> tuple[da.Array, str, PixelSize]: return arr, axes, pixel_size -def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]: - """Open an Imaris ``.ims`` file lazily → ``(array, axes, pixel_size)``.""" +def _open_imaris(path: str, level: int = 0) -> tuple[da.Array, str, PixelSize]: + """Open an Imaris ``.ims`` *level* lazily → ``(array, axes, pixel_size)``.""" try: from imaris_ims_file_reader.ims import ims except ImportError as exc: @@ -265,8 +265,8 @@ def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]: "Install it with:\n pip install 'patchworks[imaris]'" ) from exc - # Full-resolution level; the object is array-like and h5py-backed (lazy). - reader = ims(path, ResolutionLevelLock=0) + # The object is array-like and h5py-backed (lazy). + reader = ims(path, ResolutionLevelLock=level) order = _DEFAULT_ORDER[len(_DEFAULT_ORDER) - reader.ndim :] arr = da.from_array(reader, chunks=_default_chunks(reader.shape, order)) @@ -293,6 +293,46 @@ def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]: return arr, axes, pixel_size +def _write_imaris_pyramid( + path: str, + out: str, + *, + chunks: Union[tuple[int, ...], None], + overwrite: bool, +) -> str: + """Copy an Imaris file's own resolution levels into an OME-ZARR. + + Each Imaris ``ResolutionLevel`` is written as a pyramid level with its own + physical scale, so no downsampling is recomputed. Lazy (h5py-backed) reads + stream straight to disk. + """ + from imaris_ims_file_reader.ims import ims + + base = ims(path, ResolutionLevelLock=0) + n_levels = int(getattr(base, "ResolutionLevels", 1) or 1) + + zarr.open_group(out, mode="w" if overwrite else "w-") + datasets: list[dict] = [] + axes = "" + calibrated = False + for level in range(n_levels): + arr, axes, ps = _open_imaris(path, level=level) + scale = _base_scale(axes, ps) + calibrated = calibrated or bool(ps) + da.to_zarr( + arr.rechunk(chunks or _default_chunks(arr.shape, axes)), + out, + component=str(level), + overwrite=True, + ) + datasets.append(_dataset(str(level), scale)) + logger.info("imaris level %d copied: shape=%s", level, arr.shape) + _write_multiscales( + out, axes, datasets, Path(out).stem, calibrated=calibrated + ) + return out + + def _to_dask( source: Union[da.Array, np.ndarray, str, Path], axes: Union[str, None], @@ -327,6 +367,7 @@ def to_ome_zarr( n_levels: int = 5, downscale: int = 2, chunks: Union[tuple[int, ...], None] = None, + reuse_pyramid: bool = False, overwrite: bool = False, ) -> str: """Write *source* as a pyramidal, calibrated OME-ZARR store. @@ -359,6 +400,12 @@ def to_ome_zarr( Per-level X/Y downsampling factor (default 2). chunks : tuple of int, optional Chunk shape for the written levels. ``None`` → a bounded default. + reuse_pyramid : bool, optional + *Imaris ``.ims`` only.* Copy the file's **own** resolution levels + instead of rebuilding the pyramid (faster, no recompute), keeping each + level's native scale. Ignored for other inputs; falls back to a + rebuild if the Imaris levels can't be read. Default ``False`` (rebuild, + for a consistent XY-only, nearest-neighbour NGFF pyramid). overwrite : bool, optional Overwrite an existing store at *out_path*. @@ -378,6 +425,22 @@ def to_ome_zarr( if n_levels < 1: raise ValueError("n_levels must be >= 1") + # Reuse an Imaris file's own resolution pyramid instead of rebuilding it. + if ( + reuse_pyramid + and isinstance(source, (str, Path)) + and str(source).lower().endswith(".ims") + ): + try: + return _write_imaris_pyramid( + str(source), str(out_path), chunks=chunks, overwrite=overwrite + ) + except Exception as exc: + logger.warning( + "reuse_pyramid failed (%s); rebuilding the pyramid instead.", + exc, + ) + arr, axes, detected = _to_dask(source, axes, scene) if len(axes) != arr.ndim: raise ValueError( diff --git a/tests/test_ome_zarr.py b/tests/test_ome_zarr.py index ba2aa51..db12cbf 100644 --- a/tests/test_ome_zarr.py +++ b/tests/test_ome_zarr.py @@ -128,3 +128,14 @@ def test_write_labels_into_store(tmp_path): assert load_ome_zarr(group, channel=None, level=1).shape == (8, 4, 4) lg = zarr.open_group(group, mode="r") assert lg.attrs["image-label"]["version"] + + +def test_reuse_pyramid_ignored_for_arrays(tmp_path): + """reuse_pyramid only affects .ims inputs; arrays still rebuild.""" + out = to_ome_zarr( + np.zeros((8, 8, 8), "uint16"), + tmp_path / "arr.zarr", + n_levels=2, + reuse_pyramid=True, + ) + assert load_ome_zarr(out, channel=None, level=1).shape == (8, 4, 4)