Skip to content

Commit 377fbc6

Browse files
lguerardclaude
andcommitted
feat(dog): ✨ take the decon voxel sizes from the image calibration
dxdata/dzdata describe the image, and the image already records its own voxel size in the NGFF metadata -- so keeping a second copy in the config was a chance for the two to drift apart. A deconvolution given the wrong sampling does not fail; it returns a subtly wrong result, which makes the duplication worse than merely redundant. The lateral sizes now come from X/Y and the axial ones from Z, filled in only where the config left them out, so an explicitly set dxpsf (a PSF sampled differently from the data) still wins. read_pixel_size() exposes the calibration for API callers. The wiring is not DoG-specific: any custom function that declares a voxel_size parameter receives {"z", "y", "x"} from the store, so a method needing physical units -- a diameter in micrometers, say -- can ask for them rather than have them configured. An uncalibrated store passes nothing and says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a2249d5 commit 377fbc6

8 files changed

Lines changed: 282 additions & 14 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/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

tests/test_dog.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,63 @@ def test_dog_label_fn_with_tile_process():
4747

4848
assert result.shape == (1, 64, 64)
4949
assert result.max() >= 1
50+
51+
52+
def test_decon_voxel_kwargs_from_calibration():
53+
"""Voxel sizes come from the image, not from retyped config values.
54+
55+
A deconvolution told the wrong voxel size does not fail -- it returns a
56+
subtly wrong result -- so deriving them is the point.
57+
"""
58+
from patchworks.plugins.dog import decon_voxel_kwargs
59+
60+
assert decon_voxel_kwargs({"z": 0.2, "y": 0.1, "x": 0.1}) == {
61+
"dxdata": 0.1,
62+
"dzdata": 0.2,
63+
"dxpsf": 0.1,
64+
"dzpsf": 0.2,
65+
}
66+
# A PSF sampled differently from the data keeps its own sizes.
67+
assert decon_voxel_kwargs(
68+
{"z": 0.2, "y": 0.1, "x": 0.1}, {"z": 0.1, "y": 0.05, "x": 0.05}
69+
) == {"dxdata": 0.1, "dzdata": 0.2, "dxpsf": 0.05, "dzpsf": 0.1}
70+
# An uncalibrated axis is simply omitted rather than guessed.
71+
assert decon_voxel_kwargs({"y": 0.1, "x": 0.1}) == {
72+
"dxdata": 0.1,
73+
"dxpsf": 0.1,
74+
}
75+
assert decon_voxel_kwargs({}) == {}
76+
77+
78+
def test_explicit_decon_kwargs_win_over_the_calibration(monkeypatch):
79+
"""Anything set by hand must survive; only gaps are filled."""
80+
import sys
81+
import types
82+
83+
from patchworks.plugins import dog
84+
85+
captured = {}
86+
87+
def _decon(images, **kwargs):
88+
captured.update(kwargs)
89+
return images
90+
91+
monkeypatch.setitem(
92+
sys.modules, "pycudadecon", types.SimpleNamespace(decon=_decon)
93+
)
94+
monkeypatch.setattr(dog, "_require_pycudadecon", lambda: None)
95+
96+
fn = dog.dog_label_fn(
97+
low_sigma=1.0,
98+
high_sigma=3.0,
99+
threshold=0.5,
100+
# dxpsf set by hand: the PSF was sampled finer than the data
101+
decon_kwargs={"psf": "psf.tif", "dxpsf": 0.05},
102+
voxel_size={"z": 0.2, "y": 0.1, "x": 0.1},
103+
)
104+
fn(np.zeros((4, 8, 8), "uint16"))
105+
106+
assert captured["dxpsf"] == 0.05, "an explicit value must not be replaced"
107+
assert captured["dxdata"] == 0.1 # filled from the calibration
108+
assert captured["dzdata"] == 0.2
109+
assert captured["dzpsf"] == 0.2

workflow/config/config_cilia.yaml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,14 @@ custom:
4747
# use_gpu: true # optional: also run the blur/label step on GPU
4848
decon_kwargs:
4949
psf: "/path/to/psf.tif"
50-
dxpsf: 0.1
51-
dxdata: 0.1
52-
dzpsf: 0.2
53-
dzdata: 0.2
50+
# dxdata/dzdata (and dxpsf/dzpsf) are filled from image.zarr's own
51+
# calibration -- the lateral size from X/Y, the axial one from Z -- so
52+
# they cannot drift away from the image they describe. A wrong voxel
53+
# size does not fail loudly; it just deconvolves incorrectly.
54+
# Set any of them here only to override, e.g. when the PSF was sampled
55+
# differently from the data:
56+
# dxpsf: 0.05
57+
# dzpsf: 0.1
5458
wavelength: 525
5559
na: 1.4
5660
nimm: 1.515

0 commit comments

Comments
 (0)