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
25 changes: 22 additions & 3 deletions docs/api/plugins/ome_zarr.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
# OME-ZARR conversion plugin

Write any array or image file to a pyramidal OME-ZARR store. Uses only the
core dependencies for arrays and `.zarr` inputs; reading other file formats
needs the optional `bioio` extra (`pip install "patchworks[bioio]"`).
Write any array or image file to a pyramidal OME-ZARR store, add resolution
levels to an existing store, or store a label image inside an OME-ZARR under
the NGFF `labels/` group. Uses only the core dependencies for arrays and
`.zarr` inputs; reading other file formats needs the optional `bioio` extra
(`pip install "patchworks[bioio]"`).

Pyramids downsample **X and Y only** — `Z` (and channel/time) are kept at full
resolution, matching anisotropic microscopy stacks.

## to_ome_zarr

::: patchworks.plugins.ome_zarr.to_ome_zarr

## add_pyramid

::: patchworks.plugins.ome_zarr.add_pyramid

## write_labels

::: patchworks.plugins.ome_zarr.write_labels

## register_labels

::: patchworks.plugins.ome_zarr.register_labels
92 changes: 61 additions & 31 deletions docs/guide/ome_zarr_napari.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,38 @@
Two optional plugins close the loop around `tile_process`: convert any input
to a fast, pyramidal OME-ZARR, then inspect the image and its labels in napari.

## Why convert to a pyramidal OME-ZARR?
## Everything in one OME-ZARR (the default)

When you call `tile_process` on a `.zarr` store **without** `write_to`, the
labels are written **back into that same store** under the NGFF
`labels/<name>/` group, as their own multi-scale pyramid:

```python
from patchworks import tile_process

# labels land in scan.zarr/labels/labels/ with a pyramid — nothing else needed
tile_process("scan.zarr", fn)
```

After this, `scan.zarr` holds both the image and its segmentation, each
pyramidal, in a single NGFF store that napari, Fiji and validators read
natively. Pass `write_to="other.zarr"` to instead write a separate
single-resolution label store, or `output_component="cells"` to name the label
image.

The label pyramid is built lazily (`da.to_zarr`, streamed chunk by chunk), so
it stays OOM-safe even for terabyte volumes. Control it with `pyramid_levels`
and `pyramid_downscale`.

## Why a pyramid?

A single full-resolution array is slow to browse: every pan or zoom touches the
whole plane. A **pyramid** stores progressively downsampled copies, so a viewer
only reads the resolution it needs for the current zoom level. OME-ZARR is the
chunked, cloud-friendly NGFF standard that napari (and Fiji, validators, …)
read natively.
only reads the resolution it needs. Pyramids here downsample **X and Y only** —
`Z` (and channel/time) stay at full resolution, matching anisotropic microscopy
stacks. Downsampling is **strided, nearest-neighbour**: the correct choice for
label images, since interpolating label values would invent objects that never
existed.

## Convert any image to OME-ZARR

Expand All @@ -24,27 +49,34 @@ from patchworks.plugins.ome_zarr import to_ome_zarr

# From a proprietary microscope file (lazy, via bioio):
to_ome_zarr("scan.czi", "scan.zarr", n_levels=5)

# From the labels written by tile_process:
import dask.array as da
to_ome_zarr(
da.from_zarr("labels.zarr", component="labels"),
"labels_pyramid.zarr",
axes="zyx",
)
```

!!! note "Install the readers you need"
`pip install "patchworks[bioio]"` pulls `bioio` plus the `bioio-bioformats`
catch-all reader (needs a JVM). For speed, add native readers for your
formats, e.g. `bioio-ome-tiff`, `bioio-czi`, `bioio-lif`, `bioio-nd2`.

Downsampling uses **strided, nearest-neighbour** subsampling. This is the
correct choice for label images: interpolating label values would invent
objects that never existed. Only the spatial axes (`z`/`y`/`x`) are
downsampled — channel and time axes pass through unchanged.
## Add a pyramid to an existing store

## View the result in napari
Already have a flat (single-resolution) zarr? `add_pyramid` writes the missing
levels in place, lazily:

```python
from patchworks.plugins.ome_zarr import add_pyramid

add_pyramid("flat.zarr", base="0", n_levels=5)
```

And `write_labels` stores any label array inside an existing OME-ZARR under the
`labels/` group (the same thing `tile_process` does by default):

```python
from patchworks.plugins.ome_zarr import write_labels

write_labels("scan.zarr", my_labels, name="nuclei")
```

## View image + labels in napari

`view_in_napari` opens the image and overlays the labels as a proper *Labels*
layer in one call. OME-ZARR pyramids are handed to napari as a lazy multi-scale
Expand All @@ -53,15 +85,13 @@ list, so even huge stores open instantly and only on-screen data is fetched.
```python
from patchworks.plugins.napari import view_in_napari

# image as OME-ZARR, labels as the plain store from tile_process:
# one store holding both image and labels/<name>:
view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")

# or a separate plain label store written with write_to=:
view_in_napari("scan.zarr", labels="labels.zarr")
```

The label store written by `tile_process` keeps its array under the
`output_component` name (default `"labels"`); `view_in_napari` reads that
component and casts it to `int32` for the Labels layer. Pass
`labels_component=...` if you changed it.

!!! note
napari is a GUI-heavy extra and is **not** included in `patchworks[all]`.
Install it explicitly: `pip install "patchworks[napari]"`.
Expand All @@ -70,15 +100,15 @@ component and casts it to `int32` for the Labels layer. Pass

```python
from patchworks import tile_process
from patchworks.plugins.ome_zarr import to_ome_zarr
from patchworks.plugins.napari import view_in_napari

# 1. segment a large image, streaming labels to disk
tile_process("scan.zarr", fn, write_to="labels.zarr", progress=True)

# 2. (optional) make a pyramid of the raw image for snappy browsing
to_ome_zarr("scan.zarr", "scan_pyramid.zarr")
# 1. segment — labels are written into scan.zarr with a pyramid, by default
tile_process("scan.zarr", fn, progress=True)

# 3. inspect image + labels together
view_in_napari("scan_pyramid.zarr", labels="labels.zarr")
# 2. inspect image + labels together, straight from the one store
view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")
```

Plugging in a different segmentation method is just swapping `fn` — any
callable taking a tile and returning an integer label array works (see the
Cellpose and StarDist examples).
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "patchworks"
version = "0.2.0"
dynamic = ["version"]
description = "Tiled processing of arbitrarily large images with globally consistent labels"
readme = "README.md"
license = { text = "MIT" }
Expand Down Expand Up @@ -57,6 +57,9 @@ all = ["patchworks[io,gpu,bioio]", "psutil", "tqdm", "scikit-image"]
Homepage = "https://github.com/imcf/patchworks"
Issues = "https://github.com/imcf/patchworks/issues"

[tool.hatch.version]
source = "vcs"

[tool.hatch.build.targets.wheel]
packages = ["src/patchworks"]

Expand Down
8 changes: 7 additions & 1 deletion src/patchworks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,20 @@
... overlap=20, write_to="labels.zarr", progress=True)
"""

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

from ._chunks import auto_overlap, auto_tile_shape, auto_tile_shape_cellpose
from ._cluster import make_local_cluster
from ._core import tile_process
from ._io import estimate_empty_tiles, load_ome_zarr
from ._merge import merge_tile_labels
from ._relabel import relabel_sequential_array, relabel_sequential_zarr

__version__ = "0.2.0"
try:
__version__ = _pkg_version("patchworks")
except PackageNotFoundError: # not installed (e.g. running from a checkout)
__version__ = "0+unknown"
__all__ = [
"tile_process",
"merge_tile_labels",
Expand Down
83 changes: 64 additions & 19 deletions src/patchworks/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,15 @@ def tile_process(
tile_shape: Union[
tuple[int, ...], Callable[[tuple, Any], tuple], str, None
] = None,
overlap: int = 0,
overlap: int = 16,
channel: int | None = 0,
level: int = 0,
use_gpu: bool = False,
progress: bool = False,
write_to: Union[str, Path, None] = None,
output_component: str = "labels",
pyramid_levels: int = 5,
pyramid_downscale: int = 2,
sequential_labels: bool = False,
skip_empty: bool = False,
empty_threshold: float | None = None,
Expand Down Expand Up @@ -100,7 +102,9 @@ def tile_process(
Voxels of overlap (halo) added to each tile before *fn* is called, so
objects near tile boundaries have enough spatial context to be
segmented correctly (Cellpose, StarDist, …). The halo is trimmed off
before merging — the output has the original shape. ``0`` disables it.
before merging — the output has the original shape. Defaults to ``16``;
set it to roughly one object diameter (see ``auto_overlap``) for best
results, or ``0`` to disable.

Merging is always **touching-label** based: after the halo is trimmed,
labels that touch across a tile boundary are merged into one object.
Expand All @@ -113,10 +117,23 @@ def tile_process(
progress:
Show a progress bar during the tile-writing and relabel steps.
write_to:
Output zarr store path. When None, an auto-temp store is used and its
path is logged. Pass an explicit path to control the output location.
Explicit output zarr store path. Overrides the default behaviour: the
merged labels are written here as a single-resolution array named
``output_component`` (no pyramid). When None (default) and *image* is a
``.zarr`` store, labels are written back into that store under the NGFF
``labels/<output_component>/`` group with an auto pyramid, so the image
and its segmentation live in one file. When None and *image* is an
array, an auto-temp store is used.
output_component:
Array name inside ``write_to``. Default ``"labels"``.
Label name. The array inside ``write_to``, or the NGFF label image name
under ``labels/`` when writing into the input store. Default
``"labels"``.
pyramid_levels:
Number of resolution levels for the in-store label pyramid (only when
writing into the input ``.zarr``). Default 5.
pyramid_downscale:
Per-level X/Y downsampling factor for that pyramid (Z is kept at full
resolution). Default 2.
sequential_labels:
Renumber merged labels to a contiguous ``1..N`` range. Default False —
labels stay globally unique but gappy (block-encoded), which is fine for
Expand Down Expand Up @@ -144,9 +161,10 @@ def tile_process(
Returns
-------
da.Array
Globally relabeled array (int32) backed by ``write_to`` (or an
auto-temp zarr when ``write_to`` is None). Never loads the full volume
into RAM. Call ``.compute()`` yourself only if the result fits in RAM.
Globally relabeled array (int32) backed by the output zarr (the input
store's ``labels/<name>/0`` by default, ``write_to`` when given, else an
auto-temp zarr). Never loads the full volume into RAM. Call
``.compute()`` yourself only if the result fits in RAM.

Examples
--------
Expand Down Expand Up @@ -357,30 +375,57 @@ def _cleanup_stage():

_nw = min(4, os.cpu_count() or 1)

# Default: input is a .zarr store and no explicit write_to → labels go back
# *into* the input store under the NGFF labels/<name>/ group with an auto
# pyramid, so image + segmentation live in one OME-ZARR.
_into_input = (
write_to is None
and image_source_path is not None
and image_source_path.endswith(".zarr")
)

# The merge always writes its result to a concrete store first.
if write_to is not None:
_effective_out = str(write_to)
_merge_out = str(write_to)
else:
_effective_out = os.path.join(
_merge_out = os.path.join(
tempfile.mkdtemp(prefix="bb_merge_"), "merged.zarr"
)
logger.info(
"write_to not set — merged labels in auto-temp %s", _effective_out
)

zarr_native_merge(
stage_path,
"staged",
_effective_out,
_merge_out,
output_component,
n_workers=_nw,
show_progress=progress,
)
if sequential_labels:
logger.info("Relabelling to contiguous ids…")
relabel_sequential_zarr(_effective_out, output_component)
relabel_sequential_zarr(_merge_out, output_component)
_cleanup_stage()

# Always return a lazy dask array backed by the output zarr.
# Never load the full volume into RAM here — the merge already materialised
# to disk (auto-temp when write_to=None). Caller can .compute() if needed.
return da.from_zarr(_effective_out, component=output_component)
merged = da.from_zarr(_merge_out, component=output_component)
if not _into_input:
# Lazy dask array backed by the merge store. Never loads the full
# volume into RAM. Caller can .compute() if it fits.
return merged

# Stream the merged labels into the input store as an NGFF label pyramid,
# then drop the temporary merge store. write_labels uses da.to_zarr, so
# this is chunk-streamed and OOM-safe.
import shutil

from .plugins.ome_zarr import write_labels

label_group = write_labels(
image_source_path,
merged,
name=output_component,
n_levels=pyramid_levels,
downscale=pyramid_downscale,
overwrite=True,
)
shutil.rmtree(os.path.dirname(_merge_out), ignore_errors=True)
logger.info("labels stored in input OME-ZARR under %s", label_group)
return da.from_zarr(label_group, component="0")
Loading
Loading