Metadata-Version: 2.4
Name: pyrametric
Version: 0.0.2
Summary: Lazy, memory-bounded connected-components labeling and per-object measurement for OME-Zarr pyramids: label pyramids (from a mask), regionprops-like feature extraction, and label filtering (pyrops plugin; wraps tilewise-ccl)
Author-email: Bugra Oezdemir <bugraa.ozdemir@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/bugraoezdemir/pyrametric
Keywords: ome-zarr,ngff,connected-components,labeling,segmentation,regionprops,image-pyramid,bioimage,microscopy,out-of-core
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: tilewise-ccl>=0.0.5
Requires-Dist: ome_zarr_pyramid>=0.0.8
Requires-Dist: scipy
Requires-Dist: numpy
Requires-Dist: dask
Requires-Dist: zarr
Provides-Extra: dyna
Requires-Dist: ome_zarr_pyramid[dyna]>=0.0.8; extra == "dyna"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pandas; extra == "dev"
Requires-Dist: dyna-zarr>=0.0.6; extra == "dev"
Dynamic: license-file

# pyrametric

Lazy, memory-bounded **connected-components labeling and per-object
measurement** for OME-Zarr pyramids, with OME-NGFF label-image output.

`pyrametric` is the OME-Zarr-aware layer over [`tilewise-ccl`](../tilewise-ccl): it
labels the level-0 binary mask of a `Pyramid`, propagates the labels down the
resolution levels, measures per-object features, and writes the result as a
spec-compliant NGFF **label image** (`labels/<name>/` with `image-label`
metadata). It works on already-segmented masks — thresholding/segmentation
lives in [`pyrops`](../pyrops). It provides:

- **`label_pyramid`** — label a mask `Pyramid` → a lazy, writable label
  `Pyramid` (optionally with a per-object properties table).
- **`extract_features`** / **`ObjectFeatures`** — regionprops-like per-object
  measurement (area, bbox, moments, physical units, ...) over a label pyramid,
  computed lazily and memory-bounded (validated against skimage in 2D and 3D).
- **`write_label_pyramid`** — write a label pyramid as an OME-Zarr label image
  (colors, properties, `source` back-reference; default `labels/<name>/`
  location).
- **`label_array`** — re-exported from `tilewise-ccl` for array-level labeling.

The heavy lifting (fast tile-wise connected components, memory-bounded, exact,
returning a lazy dask array) lives in `tilewise-ccl`; this package adds the
pyramid, axis-handling, measurement, and NGFF I/O concerns on top.

---

## Installation

```bash
pip install pyrametric
# or, from a checkout:
pip install -e .
```

Depends on `tilewise-ccl`, `ome_zarr_pyramid`, plus `numpy`/`scipy`/`dask`/`zarr`.

---

## Quick start

The input's level 0 must already be a **binary mask** (background 0 + one
foreground value) — there is no thresholding here (binarize beforehand).

```python
from ome_zarr_pyramid.core.io import IO
from pyrametric import label_pyramid, write_label_pyramid

io = IO()
mask_pyr = io.read_pyramid("mask.zarr")           # level 0 = a binary mask

# label level 0 -> a lazy, writable label Pyramid
label_pyr = label_pyramid(mask_pyr, connectivity=2, n_workers=8)

# ...write it as a plain multiscale array
io.write_pyramid(label_pyr, "labels.zarr", overwrite=True)
```

The result mirrors `mask.zarr`'s resolution levels — you do not ask for them, and
nothing is downsampled until the write. See [Downscaling](#downscaling) to change
the depth.

### With properties + an OME-Zarr label image

```python
# also measure per-object properties (area + bounding box) - FREE from the labeling
# metadata pass; they are stamped onto the returned pyramid's image-label metadata
label_pyr = label_pyramid(mask_pyr, properties=True, n_workers=8)

# write a spec-compliant label image under the source's labels/ group:
#   mask.zarr/labels/cells/   (multiscales + image-label: colors, properties, source)
write_label_pyramid(label_pyr, source="mask.zarr", name="cells")
```

### Attaching labels to the source image, written together

`Pyramid.add_image_label` attaches a label pyramid to its source **image** pyramid,
so the two travel together and a single `write_pyramid` emits the image plus its
`labels/<name>/` sub-group in one OME-Zarr store — the common "image + its
segmentation, side by side" layout that viewers open as one dataset:

```python
image_pyr = io.read_pyramid("image.zarr")     # the intensity image
mask_pyr  = io.read_pyramid("mask.zarr")       # its binary mask (segment/threshold upstream)

# label the mask, name it, then attach it to the image. The label pyramid mirrors
# the source's levels automatically - add `.downscale(n_layers=3)` to override.
label_pyr = label_pyramid(mask_pyr, properties=True).rename("cells")
combined  = image_pyr.add_image_label(label_pyr, name="cells")

io.write_pyramid(combined, "image_with_labels.zarr", overwrite=True)
```

### Labeling a sub-region

To label only part of the image, select it first with `Pyramid.isel` (any axis,
int/slice/list) — the label matches that sub-region's geometry:

```python
label_pyr = label_pyramid(mask_pyr.isel(c=1, z=slice(100, 150)))
```

### Downscaling

`label_pyramid` takes **no downscaling parameters**. A label pyramid is downsampled
the same way a raw one is — with `Pyramid.downscale()` — so the two behave alike:

```python
labels = label_pyramid(mask_pyr)                      # mirrors mask.zarr's levels
labels = label_pyramid(mask_pyr).downscale(n_layers=5)          # exactly 5 levels
labels = label_pyramid(mask_pyr).downscale(min_dimension_size=128)  # coarser stop
```

`min_dimension_size` stops once the **largest** dimension would fall below it, so a
volume already smaller than the threshold stays single-level.

Three things are handled for you:

**Levels mirror the source.** With no `.downscale()` call, the label pyramid gets
the same number of levels as the mask it came from, at the same shapes, so the two
line up voxel-for-voxel in a viewer.

**Per-axis factors are derived from the source's own levels**, not assumed. If the
mask was built plane-wise (`z=1, y=2, x=2`) or with an irregular progression (2x to
level 1, 5x to level 2), the labels follow it exactly. Only when there is nothing to
derive from — a single-level source — does the default apply: **isotropic 2x on the
spatial axes** (`z=2, y=2, x=2`; `t`/`c` stay 1), the same default a raw pyramid
uses. When the source's levels cannot be reproduced by any integer factor,
pyrametric warns and builds a fresh plan rather than silently shifting a level by
a voxel.

**Downsampling is nearest-neighbour** (`downscale_method="simple"`, the default for
both `label_pyramid` and `.downscale()`), so a coarse level only ever contains ids
that are really present at level 0. Do **not** override it on a label pyramid:
averaging label ids invents objects that do not exist — the mean of ids 4 and 8 is
6, a different object — so `downscale_method="mean"`/`"median"` will corrupt the
labels. It is accepted, not blocked; the correctness is on you if you change it.

Nothing is computed until the write: `.downscale()` records a **plan**, and the
writer expands it progressively from disk. Level 0 is labelled exactly once,
however many levels you ask for — so `labels.nlayers` reads 1 until it is written.

---

## API

### `label_pyramid(pyramid, tile_shape=None, connectivity=2, n_workers=1, properties=False, verbose=False, backend="auto")`

Label a binary-mask `Pyramid`'s level 0. The result **mirrors the source's
resolution levels** — it takes no downscaling parameters. To choose a different
depth, call `.downscale()` on the result, exactly as you would for a raw pyramid
(see [Downscaling](#downscaling)).

The coarser levels are **recorded as a deferred plan**, not built here: the writer
expands them progressively from disk, so level 0 is labelled exactly **once**.
Building them eagerly cost one additional full re-labelling of level 0 per level
(measured 1x / 2x / 3x / 4x for 1..4 levels). Downsampling is nearest-neighbour
(stride), the only correct choice for categorical labels, and the per-axis factors
are derived from the **source** pyramid's own levels so the labels stay aligned
with their image.

Because the levels are a plan, `label_pyr.nlayers` reads 1 until the pyramid is
written; the store then contains the planned levels.

To label a sub-region, select it first with `Pyramid.isel`
(e.g. `mask.isel(c=1, z=slice(100, 150))`).

**Returns** a lazy label `Pyramid` (dtype int32 unless the object count exceeds
int32), mirroring the source's axes, units, per-level shapes and scales. With
`properties=True` the same pyramid additionally carries its OME `image-label`
metadata (per-object colors + an area/bbox properties table), so it is write-ready.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `pyramid` | `Pyramid` | — | Source pyramid; level 0 is a **binary mask** (not validated — see [Notes](#notes)). Axes may be any subset of `tczyx`. |
| `tile_shape` | sequence of int | `None` | Tile size for `tilewise-ccl`, one entry per **spatial** axis. `None` derives one from the array: a ~256 MiB working set snapped to the storage chunks. |
| `connectivity` | int | `2` | Spatial `scipy.ndimage` connectivity (2D: 1=4-, 2=8-conn; 3D: 1=6-, 2=18-, 3=26-conn). |
| `n_workers` | int | `1` | Threads for each labeling metadata pass. |
| `properties` | bool | `False` | Also measure per-object `area` + `bbox` (free from the labeling pass) and stamp them onto the returned pyramid's `image-label` metadata (colors + properties). The return type is unchanged — always a `Pyramid`. |
| `verbose` | bool | `False` | Forwarded to `tilewise-ccl.label_array`. |
| `backend` | `"auto"` \| `"dask"` \| `"dyna"` | `"auto"` | Array backend. `"auto"` uses the memory-bounded, dask-free [dyna](https://github.com/bugraoezdemir/dyna_zarr) path when the pyramid's layers are zarr-backed, and falls back to dask otherwise. Labels are identical either way. |

When `properties=True`, `label_pyramid` measures each object's **`area`** (voxel
count) and **`bbox`** (half-open bounding box, per axis, in the label's own
coordinates) and stamps them onto the returned pyramid's `image-label` metadata
(alongside deterministic per-object `colors`), so it is write-ready. For richer
per-object measurement (intensity / physical / shape features, filtering) pass the
label pyramid to `extract_features(...)`.

### `write_label_pyramid(label_pyramid, source=None, name="labels_0", output=None, write_colors=True, write_props=True, overwrite=False, **write_kwargs)`

Write a label pyramid as an OME-Zarr **label image**, serialising the `image-label`
metadata already on the pyramid (built by `label_pyramid(..., properties=True)` or
`Pyramid.set_image_label`). This writer only persists it — it does not measure.
Returns the path written.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `label_pyramid` | `Pyramid` | — | The label pyramid, carrying its `image-label` metadata. |
| `source` | str \| Path \| `Pyramid` | `None` | The source image the labels belong to. **Required when `output` is None**: labels go to `<source>/labels/<name>/`, registered in the source's `labels` group, with `image-label.source.image = "../../"`. |
| `name` | str | `"labels_0"` | Label-image name (the `labels/<name>` subgroup). |
| `output` | str \| Path | `None` | Explicit output path; overrides the default `labels/<name>` location (written standalone). |
| `write_colors` | bool | `True` | Include the pyramid's `colors` in the written metadata; `False` drops that key at write time (a lighter write for a very large object count). |
| `write_props` | bool | `True` | Include the pyramid's `properties` table (`label-value`, `area (pixels)`, `object-coordinates`); `False` drops it at write time. |
| `overwrite` | bool | `False` | Overwrite an existing label image. |
| `**write_kwargs` | | | Forwarded to `IO.write_labels` / `write_pyramid` (backend, workers, ...). |

The emitted `image-label` metadata (OME-NGFF
[labels spec](https://ngff.openmicroscopy.org/specifications/0.5/index.html#labels-metadata))
contains `version`, `colors` (`{label-value, rgba}`), `properties`
(`{label-value, "area (pixels)", "object-coordinates": {axis: [start, stop]}}`),
and `source`. Both NGFF 0.4 and 0.5 layouts are written correctly.

---

## Notes

- **Binary mask required, not validated.** The level-0 array must be a binary
  mask; this is the caller's responsibility. Validating it would force a full
  scan of a potentially huge array, so it is deliberately skipped. (Re-labeling
  an already-labeled dataset will be possible later, once `tilewise-ccl` grows a
  relabel function.)
- **Downsampling is nearest-neighbour (stride), and deferred.** Categorical labels
  must not be averaged, so coarser levels are produced by striding, with the
  per-axis factor taken from the source pyramid. They are RECORDED as a plan and
  expanded by the writer from disk, so level 0 is labelled once no matter how many
  levels you ask for.
- **Batch axes give globally-unique ids.** When multiple `(t, c)` volumes are
  labeled, each is labeled independently and its ids are offset so the whole
  output is a single, globally-unique label image.
- **Colors/properties at scale.** For very large object counts, writing explicit
  per-object colors and properties bloats the metadata (pyrametric warns) — pass
  `write_colors=False` / `write_props=False` to `write_label_pyramid` to drop them.
  Viewers such as napari auto-randomise label colors when no `colors` list is
  present, so skipping them is usually fine.
- **Memory.** Labeling and property computation are memory-bounded by tile size
  (see `tilewise-ccl`); the only object-count-proportional cost is the in-memory
  properties table.
- **Backends.** `label_pyramid` and `extract_features` both take
  `backend="auto"|"dask"|"dyna"`. `"auto"` prefers dyna when the pyramid's layers
  are zarr-backed and falls back to dask silently; a dask-backed pyramid is
  dask-only, and asking for `"dyna"` there raises rather than guessing. The results
  are identical - it is purely an execution choice.

## See also

- [`tilewise-ccl`](../tilewise-ccl) — the storage-agnostic array-level engine
  (`label_array`), with its own README and benchmarks.
