Skip to content

Commit 1cdf8e4

Browse files
committed
Merge dev: auto-tile halo fix and calibration-derived decon sizes (v2.1.0)
Fixes a v2.0.0 bug and removes a duplicated calibration. tile_shape: "auto" with do_3D: false produces z=1 tiles, so the shipped overlap [4, 30, 30] expanded each tile to 9 planes to keep 1 -- a 9x read amplification -- and then hit the 2-D guard added in v2.0.0, because Cellpose without do_3D would read those planes as channels. That combination could not run at all. An axis the tile does not span has no context to gather, so the halo is dropped there: the behaviour the tiling guide already described but stage_tile never implemented. Explicit tiles with room keep their halo. The amplification figure prepare logs now clips to the image the way stage_tile does; a tile spanning the whole image used to claim 5x while reading exactly 1x, and that number exists to be tuned from. Deconvolution voxel sizes are now derived from the image's own NGFF calibration -- lateral from X/Y, axial from Z -- instead of being retyped into the config where the two could drift apart. A deconvolution given the wrong sampling does not fail, it just returns a subtly wrong result. Values set explicitly still win, so a PSF sampled differently from the data keeps its own. The wiring is generic: any custom function declaring a voxel_size parameter receives the calibration, so a method needing physical units can ask for them rather than have them configured.
2 parents 58b68ea + 377fbc6 commit 1cdf8e4

11 files changed

Lines changed: 372 additions & 20 deletions

File tree

docs/api/plugins/ome_zarr.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ resolution, matching anisotropic microscopy stacks.
2525

2626
::: patchworks.plugins.ome_zarr.register_labels
2727

28+
## read_pixel_size
29+
30+
::: patchworks.plugins.ome_zarr.read_pixel_size
31+
2832
## NGFF metadata layout
2933

3034
NGFF 0.4 is defined over zarr v2 and puts its keys at the top level; 0.5 is

docs/examples/dog.md

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,36 @@ Requires `cupy` (matching your CUDA version, e.g. `pip install cupy-cuda12x`)
6565

6666
## With deconvolution first
6767

68+
Let the voxel sizes come from the image rather than retyping them — the
69+
lateral ones from X/Y, the axial ones from Z:
70+
6871
```python
72+
from patchworks.plugins.ome_zarr import read_pixel_size
73+
6974
fn = dog_label_fn(
7075
low_sigma=1.0, high_sigma=3.0, threshold=0.02,
71-
decon_kwargs=dict(
72-
psf=psf, dxpsf=xy_scale, dxdata=xy_scale,
73-
dzpsf=z_scale, dzdata=z_scale,
74-
wavelength=525, na=1.4, nimm=1.515,
75-
),
76+
decon_kwargs=dict(psf=psf, wavelength=525, na=1.4, nimm=1.515),
77+
voxel_size=read_pixel_size(IMAGE), # -> dxdata/dzdata/dxpsf/dzpsf
7678
)
7779
result = tile_process(IMAGE, fn, tile_shape=(1, 1024, 1024), overlap=32)
7880
```
7981

82+
Anything you set in `decon_kwargs` yourself wins, so a PSF sampled
83+
differently from the data keeps its own sizes:
84+
85+
```python
86+
decon_kwargs=dict(psf=psf, dxpsf=0.05, dzpsf=0.1, wavelength=525, ...)
87+
```
88+
89+
!!! warning "A wrong voxel size does not fail loudly"
90+
Deconvolution given the wrong sampling still runs and still returns an
91+
image — just a subtly wrong one. That is the reason to derive these from
92+
the store's own calibration instead of keeping a second copy in a config
93+
that can drift.
94+
95+
`read_pixel_size` returns `{}` for an uncalibrated store; then nothing is
96+
filled in and you must supply the sizes yourself.
97+
8098
!!! note "Deconvolution always needs a GPU"
8199
`pycudadecon` is CUDA-only, independent of `dog_label_fn`'s own `use_gpu`
82100
flag (which only picks the backend for the blur/label steps). A SLURM job
@@ -146,15 +164,27 @@ custom:
146164
threshold: 0.02
147165
decon_kwargs:
148166
psf: "/path/to/psf.tif"
149-
dxpsf: 0.1
150-
dxdata: 0.1
151-
dzpsf: 0.2
152-
dzdata: 0.2
153167
wavelength: 525
154168
na: 1.4
155169
nimm: 1.515
156170
```
157171

172+
No voxel sizes: the workflow reads `image.zarr`'s own calibration and fills
173+
in `dxdata`/`dxpsf` from X/Y and `dzdata`/`dzpsf` from Z. Set any of them in
174+
`decon_kwargs` to override — for instance a PSF sampled finer than the data:
175+
176+
```yaml
177+
dxpsf: 0.05
178+
dzpsf: 0.1
179+
```
180+
181+
!!! tip "This works for your own methods too"
182+
The injection is not DoG-specific. Any `custom` function that declares a
183+
`voxel_size` parameter receives `{"z": .., "y": .., "x": ..}` from the
184+
store, so a method needing physical units never has to keep a second copy
185+
of the calibration in its config. If the store is uncalibrated the
186+
workflow says so and passes nothing.
187+
158188
Run it exactly like a Cellpose config:
159189

160190
```bash

docs/guide/custom_segmentation.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ def segment(tile: np.ndarray, prob_thresh: float = 0.5) -> np.ndarray:
118118

119119
Using a GPU? Just let your framework see it — nothing extra needed.
120120

121+
!!! tip "Ask for the voxel size instead of configuring it"
122+
Declare a `voxel_size` parameter and the workflow passes the image's own
123+
NGFF calibration, `{"z": .., "y": .., "x": ..}` in micrometers:
124+
125+
```python
126+
def segment(tile, *, voxel_size=None, min_diameter_um=5.0):
127+
min_px = min_diameter_um / voxel_size["x"] # µm -> pixels
128+
...
129+
```
130+
131+
That keeps physical parameters tied to the image rather than duplicated
132+
in a config that can drift away from it. Setting `voxel_size` in
133+
`custom.kwargs` overrides it; an uncalibrated store passes nothing and
134+
logs a warning. This is how the DoG plugin gets its deconvolution voxel
135+
sizes.
136+
121137
!!! tip "You do not need to modify patchworks"
122138
`method: "custom"` imports any `(tile) -> labels` callable, so a new
123139
method is a module of your own and a config block — nothing in the

src/patchworks/_distributed.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
import itertools
15+
import logging
1516
from pathlib import Path
1617
from typing import Callable, Sequence, Union
1718

@@ -21,10 +22,16 @@
2122
from ._io import zarr_compressor_kwargs
2223
from ._relabel import relabel_sequential_array
2324

25+
logger = logging.getLogger(__name__)
26+
2427
Overlap = Union[int, Sequence[int]]
2528

2629

27-
def normalize_overlap(overlap: Overlap, ndim: int) -> tuple[int, ...]:
30+
def normalize_overlap(
31+
overlap: Overlap,
32+
ndim: int,
33+
tile_shape: "Sequence[int] | None" = None,
34+
) -> tuple[int, ...]:
2835
"""Expand an overlap spec to one halo width per axis.
2936
3037
A scalar applies the same halo to every axis (the historical behaviour).
@@ -33,12 +40,21 @@ def normalize_overlap(overlap: Overlap, ndim: int) -> tuple[int, ...]:
3340
``76 x 1084 x 1084`` to keep ``16 x 1024 x 1024`` -- 5.3x more voxels than
3441
it uses, nearly all of it in z.
3542
43+
With *tile_shape*, an axis only one voxel thick gets **no** halo. There is
44+
no context to gather along an axis the tile does not span, and a 2-D
45+
method handed the extra planes would read them as channels. This is the
46+
``tile_shape: "auto"`` + ``do_3D: false`` case, where tiles come out one
47+
plane thick: a z-overlap of 4 would otherwise read 9 planes per tile to
48+
keep 1.
49+
3650
Parameters
3751
----------
3852
overlap : int or sequence of int
3953
Halo width, shared or per-axis.
4054
ndim : int
4155
Number of axes the halo is applied to.
56+
tile_shape : sequence of int, optional
57+
Tile extent per axis. Used to drop halos an axis has no room for.
4258
4359
Returns
4460
-------
@@ -55,6 +71,21 @@ def normalize_overlap(overlap: Overlap, ndim: int) -> tuple[int, ...]:
5571
)
5672
if any(o < 0 for o in values):
5773
raise ValueError(f"overlap must be non-negative, got {values}")
74+
75+
if tile_shape is not None:
76+
clipped = tuple(
77+
0 if int(t) <= 1 else o for o, t in zip(values, tile_shape)
78+
)
79+
if clipped != values:
80+
dropped = [
81+
i for i, (a, b) in enumerate(zip(values, clipped)) if a != b
82+
]
83+
logger.info(
84+
"dropping the halo on axis %s: the tile is 1 voxel thick "
85+
"there, so there is no context to read.",
86+
dropped,
87+
)
88+
values = clipped
5889
return values
5990

6091

@@ -173,7 +204,7 @@ def stage_tile(
173204
"""
174205
shape = image.shape
175206
sl = spatial_tiles(shape, tile_shape)[index]
176-
halo = normalize_overlap(overlap, len(sl))
207+
halo = normalize_overlap(overlap, len(sl), tile_shape=tile_shape)
177208
expanded, trims = [], []
178209
for s, dim, ov in zip(sl, shape, halo):
179210
lo = max(0, s.start - ov)

src/patchworks/plugins/dog.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@
3434

3535
from __future__ import annotations
3636

37+
import logging
3738
from functools import partial
3839
from typing import Any, Callable
3940

4041
import numpy as np
4142

4243
from .._gpu import free_gpu_caches, retry_on_oom
4344

45+
logger = logging.getLogger(__name__)
46+
4447

4548
def _require_cupy():
4649
"""Raise an actionable ImportError if cupy is not installed.
@@ -77,13 +80,72 @@ def _require_pycudadecon():
7780
) from exc
7881

7982

83+
def decon_voxel_kwargs(
84+
voxel_size: dict[str, float], psf_voxel_size: dict[str, float] | None = None
85+
) -> dict[str, float]:
86+
"""pycudadecon's voxel-size arguments, derived from a calibration.
87+
88+
``dxdata``/``dzdata`` describe the **image**, so they come straight from
89+
its calibration. ``dxpsf``/``dzpsf`` describe the **PSF file**, which is
90+
only the same when the PSF was acquired on the same system at the same
91+
sampling -- pass *psf_voxel_size* when it was not.
92+
93+
Getting these wrong does not fail loudly: deconvolution just returns a
94+
subtly wrong result, which is why deriving them beats retyping them.
95+
96+
Parameters
97+
----------
98+
voxel_size : dict
99+
Image calibration, e.g. from
100+
:func:`patchworks.plugins.ome_zarr.read_pixel_size`. Needs ``x`` (or
101+
``y``) for the lateral size and ``z`` for the axial one.
102+
psf_voxel_size : dict, optional
103+
The PSF's own calibration. Defaults to *voxel_size*.
104+
105+
Returns
106+
-------
107+
dict
108+
``dxdata``/``dzdata``/``dxpsf``/``dzpsf``, omitting any the
109+
calibration cannot supply.
110+
111+
Examples
112+
--------
113+
>>> decon_voxel_kwargs({"z": 0.2, "y": 0.1, "x": 0.1})
114+
{'dxdata': 0.1, 'dzdata': 0.2, 'dxpsf': 0.1, 'dzpsf': 0.2}
115+
"""
116+
117+
def _lateral(cal):
118+
x, y = cal.get("x"), cal.get("y")
119+
if x and y and abs(x - y) > 1e-9 * max(x, y):
120+
logger.warning(
121+
"anisotropic lateral calibration (x=%s, y=%s); pycudadecon "
122+
"takes a single lateral size, using x.",
123+
x,
124+
y,
125+
)
126+
return x or y
127+
128+
psf_cal = psf_voxel_size if psf_voxel_size is not None else voxel_size
129+
out: dict[str, float] = {}
130+
for key, value in (
131+
("dxdata", _lateral(voxel_size)),
132+
("dzdata", voxel_size.get("z")),
133+
("dxpsf", _lateral(psf_cal)),
134+
("dzpsf", psf_cal.get("z")),
135+
):
136+
if value:
137+
out[key] = float(value)
138+
return out
139+
140+
80141
def dog_label_fn(
81142
low_sigma: float | tuple[float, ...],
82143
high_sigma: float | tuple[float, ...],
83144
threshold: float,
84145
*,
85146
use_gpu: bool = False,
86147
decon_kwargs: dict[str, Any] | None = None,
148+
voxel_size: dict[str, float] | None = None,
87149
) -> Callable[[np.ndarray], np.ndarray]:
88150
"""Return a ready-to-use DoG labeler for ``tile_process``.
89151
@@ -108,6 +170,15 @@ def dog_label_fn(
108170
PSF support when deconvolving, so edge tiles don't get truncated
109171
context.
110172
173+
``dxdata``/``dzdata``/``dxpsf``/``dzpsf`` are filled in from
174+
*voxel_size* when you leave them out, so the voxel sizes cannot drift
175+
away from the image they describe. Anything you set explicitly wins.
176+
voxel_size:
177+
Physical voxel size as ``{"z": .., "y": .., "x": ..}``. The Snakemake
178+
workflow passes the image's own calibration automatically; from the
179+
API, :func:`patchworks.plugins.ome_zarr.read_pixel_size` reads it from
180+
a store.
181+
111182
Returns
112183
-------
113184
Callable[[ndarray], ndarray]
@@ -117,6 +188,19 @@ def dog_label_fn(
117188
_require_cupy()
118189
if decon_kwargs is not None:
119190
_require_pycudadecon()
191+
if voxel_size:
192+
# Derived values fill gaps only -- an explicit dxpsf for a PSF
193+
# sampled differently from the data must not be overwritten.
194+
derived = decon_voxel_kwargs(voxel_size)
195+
missing = {
196+
k: v for k, v in derived.items() if k not in decon_kwargs
197+
}
198+
if missing:
199+
logger.info(
200+
"decon voxel sizes taken from the image calibration: %s",
201+
missing,
202+
)
203+
decon_kwargs = {**decon_kwargs, **missing}
120204
cfg = {
121205
"low_sigma": low_sigma,
122206
"high_sigma": high_sigma,

src/patchworks/plugins/ome_zarr.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,35 @@ def _write_multiscales(
743743
write_ngff_attrs(zarr.open_group(group_path, mode="a"), multiscales=[entry])
744744

745745

746+
def read_pixel_size(store: Union[str, Path]) -> PixelSize:
747+
"""Physical voxel size recorded in an OME-ZARR's level-0 metadata.
748+
749+
The calibration the conversion carried over from the source file, as
750+
``{"z": .., "y": .., "x": ..}`` in micrometers. Axes left at scale 1.0
751+
(uncalibrated) are omitted, so an empty dict means the store carries no
752+
usable calibration.
753+
754+
Use this instead of retyping voxel sizes into a config: a deconvolution
755+
told the wrong voxel size produces a plausible-looking but wrong result.
756+
757+
Parameters
758+
----------
759+
store : str or Path
760+
Path of the OME-ZARR group.
761+
762+
Returns
763+
-------
764+
dict
765+
``{axis: size}`` for calibrated spatial axes.
766+
767+
Examples
768+
--------
769+
>>> read_pixel_size("scan.zarr") # doctest: +SKIP
770+
{'z': 0.2, 'y': 0.1, 'x': 0.1}
771+
"""
772+
return _read_zarr_calibration(store, "")
773+
774+
746775
def _read_zarr_calibration(store: Union[str, Path], axes: str) -> PixelSize:
747776
"""Read level-0 spatial scale from an existing OME-ZARR, if any.
748777

0 commit comments

Comments
 (0)