patchworks 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {patchworks-2.0.0 → patchworks-2.1.0}/PKG-INFO +1 -1
  2. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/plugins/ome_zarr.md +4 -0
  3. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/dog.md +39 -9
  4. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/custom_segmentation.md +16 -0
  5. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_distributed.py +33 -2
  6. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/plugins/dog.py +84 -0
  7. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/plugins/ome_zarr.py +29 -0
  8. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_distributed.py +47 -0
  9. patchworks-2.1.0/tests/test_dog.py +109 -0
  10. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/config/config_cilia.yaml +8 -4
  11. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/_pw.py +42 -1
  12. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/prepare_tiles.py +10 -4
  13. patchworks-2.0.0/tests/test_dog.py +0 -49
  14. {patchworks-2.0.0 → patchworks-2.1.0}/.github/workflows/docs.yml +0 -0
  15. {patchworks-2.0.0 → patchworks-2.1.0}/.github/workflows/lint.yml +0 -0
  16. {patchworks-2.0.0 → patchworks-2.1.0}/.github/workflows/release.yml +0 -0
  17. {patchworks-2.0.0 → patchworks-2.1.0}/.gitignore +0 -0
  18. {patchworks-2.0.0 → patchworks-2.1.0}/.markdownlint-cli2.yaml +0 -0
  19. {patchworks-2.0.0 → patchworks-2.1.0}/LICENSE +0 -0
  20. {patchworks-2.0.0 → patchworks-2.1.0}/README.md +0 -0
  21. {patchworks-2.0.0 → patchworks-2.1.0}/cliff.toml +0 -0
  22. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/chunks.md +0 -0
  23. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/cluster.md +0 -0
  24. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/io.md +0 -0
  25. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/merge_tile_labels.md +0 -0
  26. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/plugins/cellpose.md +0 -0
  27. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/plugins/dog.md +0 -0
  28. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/plugins/napari.md +0 -0
  29. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/postprocess.md +0 -0
  30. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/relabel.md +0 -0
  31. {patchworks-2.0.0 → patchworks-2.1.0}/docs/api/tile_process.md +0 -0
  32. {patchworks-2.0.0 → patchworks-2.1.0}/docs/assets/logo.png +0 -0
  33. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/cellpose_2d.md +0 -0
  34. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/cellpose_2d.py +0 -0
  35. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/cellpose_3d.md +0 -0
  36. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/cellpose_3d.py +0 -0
  37. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/custom.md +0 -0
  38. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/custom_method.py +0 -0
  39. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/dog.py +0 -0
  40. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/standalone_merge.md +0 -0
  41. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/stardist.md +0 -0
  42. {patchworks-2.0.0 → patchworks-2.1.0}/docs/examples/stardist_2d.py +0 -0
  43. {patchworks-2.0.0 → patchworks-2.1.0}/docs/getting_started.md +0 -0
  44. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/gpu_distributed.md +0 -0
  45. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/label_relations.md +0 -0
  46. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/measurements.md +0 -0
  47. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/merging.md +0 -0
  48. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/ome_zarr_napari.md +0 -0
  49. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/performance.md +0 -0
  50. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/pitfalls.md +0 -0
  51. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/skip_empty.md +0 -0
  52. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/snakemake.md +0 -0
  53. {patchworks-2.0.0 → patchworks-2.1.0}/docs/guide/tiling.md +0 -0
  54. {patchworks-2.0.0 → patchworks-2.1.0}/docs/index.md +0 -0
  55. {patchworks-2.0.0 → patchworks-2.1.0}/mkdocs.yml +0 -0
  56. {patchworks-2.0.0 → patchworks-2.1.0}/pyproject.toml +0 -0
  57. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/__init__.py +0 -0
  58. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_chunks.py +0 -0
  59. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_cluster.py +0 -0
  60. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_core.py +0 -0
  61. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_gpu.py +0 -0
  62. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_io.py +0 -0
  63. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_merge.py +0 -0
  64. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_occupancy.py +0 -0
  65. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_postprocess.py +0 -0
  66. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_relabel.py +0 -0
  67. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/_relations.py +0 -0
  68. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/plugins/__init__.py +0 -0
  69. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/plugins/cellpose.py +0 -0
  70. {patchworks-2.0.0 → patchworks-2.1.0}/src/patchworks/plugins/napari.py +0 -0
  71. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_allocation.py +0 -0
  72. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_core.py +0 -0
  73. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_gpu.py +0 -0
  74. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_napari.py +0 -0
  75. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_occupancy.py +0 -0
  76. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_ome_zarr.py +0 -0
  77. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_postprocess.py +0 -0
  78. {patchworks-2.0.0 → patchworks-2.1.0}/tests/test_relations.py +0 -0
  79. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/README.md +0 -0
  80. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/Snakefile +0 -0
  81. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/config/config.yaml +0 -0
  82. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/config/config_cyto.yaml +0 -0
  83. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/config/config_nuclei.yaml +0 -0
  84. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/config/multi.yaml +0 -0
  85. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/pixi.toml +0 -0
  86. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/profile/slurm/config.yaml +0 -0
  87. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/rules/common.smk +0 -0
  88. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/rules/convert.smk +0 -0
  89. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/rules/merge.smk +0 -0
  90. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/rules/segment.smk +0 -0
  91. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/convert.py +0 -0
  92. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/fetch_model.py +0 -0
  93. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/merge.py +0 -0
  94. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/run_multi.py +0 -0
  95. {patchworks-2.0.0 → patchworks-2.1.0}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Tiled processing of arbitrarily large images with globally consistent labels
5
5
  Project-URL: Homepage, https://github.com/imcf/patchworks
6
6
  Project-URL: Issues, https://github.com/imcf/patchworks/issues
@@ -25,6 +25,10 @@ resolution, matching anisotropic microscopy stacks.
25
25
 
26
26
  ::: patchworks.plugins.ome_zarr.register_labels
27
27
 
28
+ ## read_pixel_size
29
+
30
+ ::: patchworks.plugins.ome_zarr.read_pixel_size
31
+
28
32
  ## NGFF metadata layout
29
33
 
30
34
  NGFF 0.4 is defined over zarr v2 and puts its keys at the top level; 0.5 is
@@ -65,18 +65,36 @@ Requires `cupy` (matching your CUDA version, e.g. `pip install cupy-cuda12x`)
65
65
 
66
66
  ## With deconvolution first
67
67
 
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
+
68
71
  ```python
72
+ from patchworks.plugins.ome_zarr import read_pixel_size
73
+
69
74
  fn = dog_label_fn(
70
75
  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
76
78
  )
77
79
  result = tile_process(IMAGE, fn, tile_shape=(1, 1024, 1024), overlap=32)
78
80
  ```
79
81
 
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
+
80
98
  !!! note "Deconvolution always needs a GPU"
81
99
  `pycudadecon` is CUDA-only, independent of `dog_label_fn`'s own `use_gpu`
82
100
  flag (which only picks the backend for the blur/label steps). A SLURM job
@@ -146,15 +164,27 @@ custom:
146
164
  threshold: 0.02
147
165
  decon_kwargs:
148
166
  psf: "/path/to/psf.tif"
149
- dxpsf: 0.1
150
- dxdata: 0.1
151
- dzpsf: 0.2
152
- dzdata: 0.2
153
167
  wavelength: 525
154
168
  na: 1.4
155
169
  nimm: 1.515
156
170
  ```
157
171
 
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
+
158
188
  Run it exactly like a Cellpose config:
159
189
 
160
190
  ```bash
@@ -118,6 +118,22 @@ def segment(tile: np.ndarray, prob_thresh: float = 0.5) -> np.ndarray:
118
118
 
119
119
  Using a GPU? Just let your framework see it — nothing extra needed.
120
120
 
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
+
121
137
  !!! tip "You do not need to modify patchworks"
122
138
  `method: "custom"` imports any `(tile) -> labels` callable, so a new
123
139
  method is a module of your own and a config block — nothing in the
@@ -12,6 +12,7 @@ it into that store. Stitch the result with
12
12
  from __future__ import annotations
13
13
 
14
14
  import itertools
15
+ import logging
15
16
  from pathlib import Path
16
17
  from typing import Callable, Sequence, Union
17
18
 
@@ -21,10 +22,16 @@ import zarr
21
22
  from ._io import zarr_compressor_kwargs
22
23
  from ._relabel import relabel_sequential_array
23
24
 
25
+ logger = logging.getLogger(__name__)
26
+
24
27
  Overlap = Union[int, Sequence[int]]
25
28
 
26
29
 
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, ...]:
28
35
  """Expand an overlap spec to one halo width per axis.
29
36
 
30
37
  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, ...]:
33
40
  ``76 x 1084 x 1084`` to keep ``16 x 1024 x 1024`` -- 5.3x more voxels than
34
41
  it uses, nearly all of it in z.
35
42
 
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
+
36
50
  Parameters
37
51
  ----------
38
52
  overlap : int or sequence of int
39
53
  Halo width, shared or per-axis.
40
54
  ndim : int
41
55
  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.
42
58
 
43
59
  Returns
44
60
  -------
@@ -55,6 +71,21 @@ def normalize_overlap(overlap: Overlap, ndim: int) -> tuple[int, ...]:
55
71
  )
56
72
  if any(o < 0 for o in values):
57
73
  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
58
89
  return values
59
90
 
60
91
 
@@ -173,7 +204,7 @@ def stage_tile(
173
204
  """
174
205
  shape = image.shape
175
206
  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)
177
208
  expanded, trims = [], []
178
209
  for s, dim, ov in zip(sl, shape, halo):
179
210
  lo = max(0, s.start - ov)
@@ -34,6 +34,7 @@ docs/guide/custom_segmentation.md), use the
34
34
 
35
35
  from __future__ import annotations
36
36
 
37
+ import logging
37
38
  from functools import partial
38
39
  from typing import Any, Callable
39
40
 
@@ -41,6 +42,8 @@ import numpy as np
41
42
 
42
43
  from .._gpu import free_gpu_caches, retry_on_oom
43
44
 
45
+ logger = logging.getLogger(__name__)
46
+
44
47
 
45
48
  def _require_cupy():
46
49
  """Raise an actionable ImportError if cupy is not installed.
@@ -77,6 +80,64 @@ def _require_pycudadecon():
77
80
  ) from exc
78
81
 
79
82
 
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
+
80
141
  def dog_label_fn(
81
142
  low_sigma: float | tuple[float, ...],
82
143
  high_sigma: float | tuple[float, ...],
@@ -84,6 +145,7 @@ def dog_label_fn(
84
145
  *,
85
146
  use_gpu: bool = False,
86
147
  decon_kwargs: dict[str, Any] | None = None,
148
+ voxel_size: dict[str, float] | None = None,
87
149
  ) -> Callable[[np.ndarray], np.ndarray]:
88
150
  """Return a ready-to-use DoG labeler for ``tile_process``.
89
151
 
@@ -108,6 +170,15 @@ def dog_label_fn(
108
170
  PSF support when deconvolving, so edge tiles don't get truncated
109
171
  context.
110
172
 
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
+
111
182
  Returns
112
183
  -------
113
184
  Callable[[ndarray], ndarray]
@@ -117,6 +188,19 @@ def dog_label_fn(
117
188
  _require_cupy()
118
189
  if decon_kwargs is not None:
119
190
  _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}
120
204
  cfg = {
121
205
  "low_sigma": low_sigma,
122
206
  "high_sigma": high_sigma,
@@ -743,6 +743,35 @@ def _write_multiscales(
743
743
  write_ngff_attrs(zarr.open_group(group_path, mode="a"), multiscales=[entry])
744
744
 
745
745
 
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
+
746
775
  def _read_zarr_calibration(store: Union[str, Path], axes: str) -> PixelSize:
747
776
  """Read level-0 spatial scale from an existing OME-ZARR, if any.
748
777
 
@@ -41,6 +41,53 @@ def test_normalize_overlap_scalar_and_per_axis():
41
41
  normalize_overlap([-1, 0, 0], 3)
42
42
 
43
43
 
44
+ def test_no_halo_on_a_one_voxel_thick_axis():
45
+ """A 1-plane tile must get no halo on that axis.
46
+
47
+ `tile_shape: "auto"` with `do_3D: false` produces z=1 tiles, so a z-overlap
48
+ of 4 would read 9 planes to keep 1 (9x) -- and hand a 2-D method a stack it
49
+ would read as channels.
50
+ """
51
+ assert normalize_overlap([4, 30, 30], 3, tile_shape=(1, 8000, 8000)) == (
52
+ 0,
53
+ 30,
54
+ 30,
55
+ )
56
+ # A tile with room keeps its halo.
57
+ assert normalize_overlap([4, 30, 30], 3, tile_shape=(16, 1024, 1024)) == (
58
+ 4,
59
+ 30,
60
+ 30,
61
+ )
62
+ # Without a tile_shape nothing is clipped (the historical behaviour).
63
+ assert normalize_overlap([4, 30, 30], 3) == (4, 30, 30)
64
+
65
+
66
+ def test_single_plane_tiles_segment_without_a_z_halo(tmp_path):
67
+ """End to end: z=1 tiles must not pull neighbouring planes into the tile."""
68
+ img = np.zeros((4, 32), "uint16")
69
+ img[1, 4:10] = 500 # only plane 1 has signal
70
+ tile = (1, 16)
71
+
72
+ stage = str(tmp_path / "stage.zarr")
73
+ create_stage(stage, img.shape, tile)
74
+ seen = []
75
+
76
+ def _recording(block):
77
+ seen.append(block.shape)
78
+ return _fn(block)
79
+
80
+ for i in range(len(spatial_tiles(img.shape, tile))):
81
+ stage_tile(img, _recording, stage, i, tile_shape=tile, overlap=[3, 2])
82
+
83
+ assert all(s[0] == 1 for s in seen), (
84
+ f"a z=1 tile was handed multiple planes: {seen}"
85
+ )
86
+ written = np.asarray(zarr.open_group(stage, mode="r")["staged"])
87
+ assert (written[0] == 0).all(), "plane 0 has no signal and must stay empty"
88
+ assert (written[1, 4:10] > 0).all()
89
+
90
+
44
91
  def test_auto_overlap_anisotropy_shrinks_z():
45
92
  """A 20x coarser z step needs 20x fewer planes for the same distance."""
46
93
  assert auto_overlap(30) == 30 # unchanged without voxel_size
@@ -0,0 +1,109 @@
1
+ """Self-contained tests for the dog plugin. No frameworks, no fixtures."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _make_blob_image(shape=(1, 64, 64)):
7
+ img = np.zeros(shape, dtype="float32")
8
+ img[0, 28:36, 28:36] = 1.0
9
+ return img
10
+
11
+
12
+ def test_dog_label_fn_cpu_finds_blob():
13
+ from patchworks.plugins.dog import dog_label_fn
14
+
15
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
16
+ labels = fn(_make_blob_image())
17
+
18
+ assert labels.shape == (1, 64, 64)
19
+ assert labels.dtype == np.int32
20
+ assert labels.max() >= 1 # the blob was detected
21
+ assert labels[0, 0, 0] == 0 # background stays unlabeled
22
+
23
+
24
+ def test_segment_adapter_matches_factory():
25
+ # method: "custom" calls segment(tile, **kwargs) directly — must match
26
+ # dog_label_fn(**kwargs)(tile) exactly.
27
+ from patchworks.plugins.dog import dog_label_fn, segment
28
+
29
+ kwargs = dict(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
30
+ img = _make_blob_image()
31
+
32
+ via_adapter = segment(img, **kwargs)
33
+ via_factory = dog_label_fn(**kwargs)(img)
34
+
35
+ np.testing.assert_array_equal(via_adapter, via_factory)
36
+
37
+
38
+ def test_dog_label_fn_with_tile_process():
39
+ import dask.array as da
40
+
41
+ from patchworks import tile_process
42
+ from patchworks.plugins.dog import dog_label_fn
43
+
44
+ arr = da.from_array(_make_blob_image((1, 64, 64)), chunks=(1, 64, 64))
45
+ fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
46
+ result = tile_process(arr, fn, overlap=4).compute()
47
+
48
+ assert result.shape == (1, 64, 64)
49
+ 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
@@ -47,10 +47,14 @@ custom:
47
47
  # use_gpu: true # optional: also run the blur/label step on GPU
48
48
  decon_kwargs:
49
49
  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
54
58
  wavelength: 525
55
59
  na: 1.4
56
60
  nimm: 1.515
@@ -131,6 +131,46 @@ def load_tiles_json(path):
131
131
  return json.loads(Path(path).read_text())
132
132
 
133
133
 
134
+ def _with_voxel_size(fn, kwargs, cfg):
135
+ """Pass the image's calibration to a function that asks for it.
136
+
137
+ A method that needs physical units -- a deconvolution's voxel sizes, a
138
+ diameter in micrometers -- should not have them retyped into the config
139
+ where they can drift away from the image. Any custom function that
140
+ declares a ``voxel_size`` parameter gets ``{"z": .., "y": .., "x": ..}``
141
+ from the store's own NGFF calibration, unless the config sets it.
142
+
143
+ Returns *kwargs* unchanged when the function does not take it, the config
144
+ already supplied it, or the store carries no calibration.
145
+ """
146
+ import inspect
147
+
148
+ if "voxel_size" in kwargs:
149
+ return kwargs
150
+ # A **kwargs passthrough names its real target (see dog.segment).
151
+ target = getattr(fn, "patchworks_kwargs_target", fn)
152
+ try:
153
+ params = inspect.signature(target).parameters
154
+ except (TypeError, ValueError):
155
+ return kwargs
156
+ if "voxel_size" not in params:
157
+ return kwargs
158
+
159
+ from patchworks.plugins.ome_zarr import read_pixel_size
160
+
161
+ store = str(Path(cfg["work_dir"]) / "image.zarr")
162
+ calibration = read_pixel_size(store)
163
+ if not calibration:
164
+ logging.getLogger(__name__).warning(
165
+ "%s takes voxel_size but %s carries no calibration; set the "
166
+ "physical sizes in the config instead.",
167
+ getattr(target, "__name__", target),
168
+ store,
169
+ )
170
+ return kwargs
171
+ return {**kwargs, "voxel_size": calibration}
172
+
173
+
134
174
  def _unknown_kwargs(fn, provided, *, skip=()) -> list[str]:
135
175
  """Keys in *provided* that *fn* would reject, ignoring *skip*.
136
176
 
@@ -314,7 +354,8 @@ def _build_method_fn(cfg):
314
354
  importlib.import_module(spec["module"]),
315
355
  spec.get("function", "segment"),
316
356
  )
317
- kwargs = spec.get("kwargs") or {}
357
+ kwargs = dict(spec.get("kwargs") or {})
358
+ kwargs = _with_voxel_size(fn, kwargs, cfg)
318
359
  return partial(fn, **kwargs) if kwargs else fn
319
360
 
320
361
  if method == "threshold":
@@ -85,7 +85,9 @@ if len(tile_shape) >= 3:
85
85
  # A halo wider than the tile itself is not boundary context -- it means every
86
86
  # tile re-reads and re-segments its neighbours. Catch it here rather than
87
87
  # paying 5x the GPU time for results that get trimmed away.
88
- overlap = normalize_overlap(cfg.get("overlap", 0), len(tile_shape))
88
+ overlap = normalize_overlap(
89
+ cfg.get("overlap", 0), len(tile_shape), tile_shape=tile_shape
90
+ )
89
91
  for axis, (ov, extent) in enumerate(zip(overlap, tile_shape)):
90
92
  if ov >= extent:
91
93
  raise ValueError(
@@ -93,9 +95,13 @@ for axis, (ov, extent) in enumerate(zip(overlap, tile_shape)):
93
95
  f"would read past its neighbours. Use a per-axis overlap, e.g. "
94
96
  f"overlap: {list(max(1, t // 4) for t in tile_shape)}"
95
97
  )
96
- amplification = np.prod(
97
- [(t + 2 * o) for t, o in zip(tile_shape, overlap)]
98
- ) / np.prod(tile_shape)
98
+ # Clip the halo to the image the way stage_tile does, or a tile that already
99
+ # spans an axis reports a halo it will never actually read -- a tile covering
100
+ # the whole image would claim 5x while reading exactly 1x.
101
+ read = np.prod(
102
+ [min(t + 2 * o, s) for t, o, s in zip(tile_shape, overlap, image.shape)]
103
+ )
104
+ amplification = read / np.prod(tile_shape)
99
105
  print(f"[patchworks] halo read amplification: {amplification:.2f}x")
100
106
 
101
107
  tiles = spatial_tiles(image.shape, tile_shape)
@@ -1,49 +0,0 @@
1
- """Self-contained tests for the dog plugin. No frameworks, no fixtures."""
2
-
3
- import numpy as np
4
-
5
-
6
- def _make_blob_image(shape=(1, 64, 64)):
7
- img = np.zeros(shape, dtype="float32")
8
- img[0, 28:36, 28:36] = 1.0
9
- return img
10
-
11
-
12
- def test_dog_label_fn_cpu_finds_blob():
13
- from patchworks.plugins.dog import dog_label_fn
14
-
15
- fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
16
- labels = fn(_make_blob_image())
17
-
18
- assert labels.shape == (1, 64, 64)
19
- assert labels.dtype == np.int32
20
- assert labels.max() >= 1 # the blob was detected
21
- assert labels[0, 0, 0] == 0 # background stays unlabeled
22
-
23
-
24
- def test_segment_adapter_matches_factory():
25
- # method: "custom" calls segment(tile, **kwargs) directly — must match
26
- # dog_label_fn(**kwargs)(tile) exactly.
27
- from patchworks.plugins.dog import dog_label_fn, segment
28
-
29
- kwargs = dict(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
30
- img = _make_blob_image()
31
-
32
- via_adapter = segment(img, **kwargs)
33
- via_factory = dog_label_fn(**kwargs)(img)
34
-
35
- np.testing.assert_array_equal(via_adapter, via_factory)
36
-
37
-
38
- def test_dog_label_fn_with_tile_process():
39
- import dask.array as da
40
-
41
- from patchworks import tile_process
42
- from patchworks.plugins.dog import dog_label_fn
43
-
44
- arr = da.from_array(_make_blob_image((1, 64, 64)), chunks=(1, 64, 64))
45
- fn = dog_label_fn(low_sigma=1.0, high_sigma=4.0, threshold=0.01)
46
- result = tile_process(arr, fn, overlap=4).compute()
47
-
48
- assert result.shape == (1, 64, 64)
49
- assert result.max() >= 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes