Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs/guide/ome_zarr_napari.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 67 additions & 4 deletions src/patchworks/plugins/ome_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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))

Expand All @@ -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],
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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*.

Expand All @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions tests/test_ome_zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading