patchworks 2.5.0__tar.gz → 2.5.2__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.
- {patchworks-2.5.0 → patchworks-2.5.2}/PKG-INFO +1 -1
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/snakemake.md +18 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_distributed.py +16 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_io.py +55 -1
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_notify.py +42 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/plugins/dog.py +64 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_core.py +32 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_dog.py +53 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_notify.py +35 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/Snakefile +21 -11
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/run_multi.py +70 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/.github/workflows/docs.yml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/.github/workflows/lint.yml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/.github/workflows/release.yml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/.gitignore +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/LICENSE +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/README.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/cliff.toml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/chunks.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/cluster.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/io.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/postprocess.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/relabel.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/api/tile_process.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/assets/logo.png +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/custom.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/custom_method.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/dog.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/dog.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/stardist.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/getting_started.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/label_relations.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/measurements.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/merging.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/performance.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/guide/tiling.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/docs/index.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/mkdocs.yml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/pyproject.toml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/__init__.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_core.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_merge.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_progress.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/_relations.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_allocation.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_distributed.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_gpu.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_napari.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_occupancy.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_postprocess.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_progress.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_relations.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/tests/test_run_multi.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/README.md +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/common.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/config.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/config/multi.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/pixi.toml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/rules/common.smk +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/rules/convert.smk +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/rules/merge.smk +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/rules/segment.smk +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/convert.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/merge.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.5.0 → patchworks-2.5.2}/workflow/scripts/segment_tile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.5.
|
|
3
|
+
Version: 2.5.2
|
|
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
|
|
@@ -475,6 +475,24 @@ These cover what SLURM cannot: a local run with no scheduler at all, and
|
|
|
475
475
|
failures where the useful content is the Python traceback rather than an exit
|
|
476
476
|
code.
|
|
477
477
|
|
|
478
|
+
Check it works without waiting for a multi-hour step:
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
python scripts/run_multi.py --config config/multi.yaml --test-email
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
It prints the address it actually resolved from the merged config, the exact
|
|
485
|
+
`sbatch` flags the jobs will carry, and whether a test message was accepted —
|
|
486
|
+
which separates "the address never reached the config" from "it did and the
|
|
487
|
+
mail was dropped downstream". Those look identical otherwise: no email either
|
|
488
|
+
way.
|
|
489
|
+
|
|
490
|
+
!!! warning "No mail from `segment`"
|
|
491
|
+
`segment` is excluded on purpose, so a run that is only segmenting tiles
|
|
492
|
+
sends nothing. Mail comes from `convert`, `occupancy` and `merge`, plus
|
|
493
|
+
the workflow-level success/failure message — if those already completed
|
|
494
|
+
before you set the address, there is nothing left in the run to mail you.
|
|
495
|
+
|
|
478
496
|
!!! note "Delivery is best-effort, by design"
|
|
479
497
|
A notification can never fail a run. If no local `sendmail` exists and no
|
|
480
498
|
SMTP server answers on localhost, the failure is logged as a warning and
|
|
@@ -213,6 +213,22 @@ def stage_tile(
|
|
|
213
213
|
trims.append((s.start - lo, hi - s.stop))
|
|
214
214
|
block = np.asarray(image[tuple(expanded)])
|
|
215
215
|
out = np.asarray(fn(block))
|
|
216
|
+
if out.shape != block.shape:
|
|
217
|
+
# Caught here rather than 6 frames deep in zarr's codec pipeline as
|
|
218
|
+
# "could not broadcast input array from shape (13,1020,1020) into
|
|
219
|
+
# shape (14,1024,1024)", which says nothing about which function is
|
|
220
|
+
# at fault. A segmentation function must label the voxels it was
|
|
221
|
+
# given: the halo trim and the destination slice are both computed
|
|
222
|
+
# from the tile's geometry, so a different shape has no defined
|
|
223
|
+
# placement.
|
|
224
|
+
name = getattr(fn, "__name__", type(fn).__name__)
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"segmentation function {name!r} returned shape {out.shape} for "
|
|
227
|
+
f"a tile of shape {block.shape} (tile {index}). It must return "
|
|
228
|
+
"one label per input voxel. Some deconvolution backends crop "
|
|
229
|
+
"their output -- pad or centre it back to the input shape before "
|
|
230
|
+
"returning."
|
|
231
|
+
)
|
|
216
232
|
sel = tuple(
|
|
217
233
|
slice(left, out.shape[i] - right)
|
|
218
234
|
for i, (left, right) in enumerate(trims)
|
|
@@ -95,7 +95,61 @@ def load_ome_zarr(
|
|
|
95
95
|
|
|
96
96
|
arr = da.from_zarr(str(store_path), component=path, chunks=zarr_chunks)
|
|
97
97
|
if channel is not None:
|
|
98
|
-
arr = arr[
|
|
98
|
+
arr = _select_channel(arr, channel, _ms[0], store_path)
|
|
99
|
+
return arr
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _select_channel(arr, channel: int, multiscale: dict, store_path):
|
|
103
|
+
"""Index *arr*'s channel axis, or leave it alone when there isn't one.
|
|
104
|
+
|
|
105
|
+
``arr[channel]`` used to be applied unconditionally, so on a
|
|
106
|
+
single-channel store written as plain ``zyx`` the default ``channel: 0``
|
|
107
|
+
silently sliced away **z** instead. The result stayed a valid array, just
|
|
108
|
+
one dimension short, and surfaced much later as a tile-count mismatch
|
|
109
|
+
against the occupancy grid rather than as anything about channels.
|
|
110
|
+
|
|
111
|
+
Parameters
|
|
112
|
+
----------
|
|
113
|
+
arr : da.Array
|
|
114
|
+
The full array as stored.
|
|
115
|
+
channel : int
|
|
116
|
+
Requested channel index.
|
|
117
|
+
multiscale : dict
|
|
118
|
+
The store's multiscales entry, read for its ``axes``.
|
|
119
|
+
store_path : str or Path
|
|
120
|
+
Only used in messages.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
da.Array
|
|
125
|
+
*arr* with the channel axis indexed away, or unchanged when the store
|
|
126
|
+
has no channel axis and channel 0 was requested.
|
|
127
|
+
"""
|
|
128
|
+
axes = [
|
|
129
|
+
(a.get("name") if isinstance(a, dict) else a) or ""
|
|
130
|
+
for a in (multiscale.get("axes") or [])
|
|
131
|
+
]
|
|
132
|
+
if "c" in axes:
|
|
133
|
+
idx = axes.index("c")
|
|
134
|
+
return arr[(slice(None),) * idx + (channel,)]
|
|
135
|
+
|
|
136
|
+
# No axes metadata: fall back to shape. A 4-D array is c,z,y,x by the
|
|
137
|
+
# convention this package writes; a 3-D one is z,y,x.
|
|
138
|
+
if not axes and arr.ndim >= 4:
|
|
139
|
+
return arr[channel]
|
|
140
|
+
|
|
141
|
+
if channel:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"channel={channel} was requested but {store_path!r} has no "
|
|
144
|
+
f"channel axis (axes={axes or 'unknown'}, shape={arr.shape}). "
|
|
145
|
+
"Set channel: null in the config for a single-channel image."
|
|
146
|
+
)
|
|
147
|
+
logger.info(
|
|
148
|
+
"%s has no channel axis; ignoring channel=0 and using the whole "
|
|
149
|
+
"array (shape %s).",
|
|
150
|
+
store_path,
|
|
151
|
+
arr.shape,
|
|
152
|
+
)
|
|
99
153
|
return arr
|
|
100
154
|
|
|
101
155
|
|
|
@@ -14,6 +14,7 @@ turn a real error into a confusing one about email.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import logging
|
|
17
|
+
import re
|
|
17
18
|
import shutil
|
|
18
19
|
import socket
|
|
19
20
|
import subprocess
|
|
@@ -155,3 +156,44 @@ def slurm_mail_extra(
|
|
|
155
156
|
# Keep SLURM's own order, not the config's, so the string is stable.
|
|
156
157
|
types = [mapping[k] for k in ("start", "finish", "error") if k in chosen]
|
|
157
158
|
return f"--mail-type={','.join(types)} --mail-user={email}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def failing_step(
|
|
162
|
+
snakemake_log: Union[str, Path, None],
|
|
163
|
+
) -> "tuple[Union[str, None], Union[str, None]]":
|
|
164
|
+
"""Find which rule failed, and its log, from Snakemake's own log file.
|
|
165
|
+
|
|
166
|
+
Guessing from step-log timestamps does not work: a `segment` failure
|
|
167
|
+
leaves `prepare.log` as the most recently written of the sequential
|
|
168
|
+
steps, so a mail built that way quotes a log that *succeeded* and names
|
|
169
|
+
the wrong step. Snakemake records the failing rule and the exact log path
|
|
170
|
+
it used, so read that instead.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
snakemake_log : str or Path or None
|
|
175
|
+
Path to Snakemake's own log (the ``log`` variable inside an
|
|
176
|
+
``onerror`` handler).
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
tuple
|
|
181
|
+
``(rule_name, log_path)``, either of which may be None when the log
|
|
182
|
+
is unreadable or records no rule error.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
text = Path(snakemake_log).read_text(errors="replace")
|
|
186
|
+
except (OSError, TypeError, ValueError):
|
|
187
|
+
return None, None
|
|
188
|
+
|
|
189
|
+
blocks = text.split("Error in rule ")
|
|
190
|
+
if len(blocks) < 2:
|
|
191
|
+
return None, None
|
|
192
|
+
last = blocks[-1]
|
|
193
|
+
rule = re.match(r"(\S+?):", last)
|
|
194
|
+
# The log: line inside that error block points at the step's own log.
|
|
195
|
+
path = re.search(r"^\s*log:\s*(\S+?)(?:,|\s|$)", last, re.M)
|
|
196
|
+
return (
|
|
197
|
+
rule.group(1) if rule else None,
|
|
198
|
+
path.group(1) if path else None,
|
|
199
|
+
)
|
|
@@ -238,6 +238,61 @@ def _run(block: np.ndarray, dog_dict: dict[str, Any]) -> np.ndarray:
|
|
|
238
238
|
)
|
|
239
239
|
|
|
240
240
|
|
|
241
|
+
def _restore_shape(arr: np.ndarray, shape: tuple[int, ...]) -> np.ndarray:
|
|
242
|
+
"""Centre *arr* back into an array of *shape*, cropping or edge-padding.
|
|
243
|
+
|
|
244
|
+
Deconvolution must not change the field of view: patchworks writes the
|
|
245
|
+
result into a destination slice derived from the tile's geometry, so one
|
|
246
|
+
label per input voxel is required.
|
|
247
|
+
|
|
248
|
+
Centring is the right correction for a symmetric crop, which is what
|
|
249
|
+
apodisation produces. The discrepancies observed are small (a voxel in z,
|
|
250
|
+
a few in x/y) and land inside the halo, which is discarded anyway -- so
|
|
251
|
+
the labels that survive the trim are unaffected. It is logged at WARNING
|
|
252
|
+
with the exact shapes so a larger, non-symmetric crop cannot pass
|
|
253
|
+
silently.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
arr : np.ndarray
|
|
258
|
+
The deconvolved volume.
|
|
259
|
+
shape : tuple of int
|
|
260
|
+
The shape it must be returned at (the input tile's).
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
np.ndarray
|
|
265
|
+
An array of exactly *shape*.
|
|
266
|
+
|
|
267
|
+
Examples
|
|
268
|
+
--------
|
|
269
|
+
>>> import numpy as np
|
|
270
|
+
>>> _restore_shape(np.ones((13, 1020)), (14, 1024)).shape
|
|
271
|
+
(14, 1024)
|
|
272
|
+
"""
|
|
273
|
+
logger.warning(
|
|
274
|
+
"deconvolution returned %s for a %s input; re-centring to the input "
|
|
275
|
+
"shape. patchworks needs one label per input voxel. A large or "
|
|
276
|
+
"asymmetric difference here would shift labels -- check the PSF and "
|
|
277
|
+
"voxel sizes if this is more than a few voxels.",
|
|
278
|
+
arr.shape,
|
|
279
|
+
shape,
|
|
280
|
+
)
|
|
281
|
+
# Crop first, so an axis that grew is handled before padding the rest.
|
|
282
|
+
crop = tuple(
|
|
283
|
+
slice((a - s) // 2, (a - s) // 2 + s) if a > s else slice(None)
|
|
284
|
+
for a, s in zip(arr.shape, shape)
|
|
285
|
+
)
|
|
286
|
+
arr = arr[crop]
|
|
287
|
+
pad = tuple(
|
|
288
|
+
((s - a) // 2, s - a - (s - a) // 2) if a < s else (0, 0)
|
|
289
|
+
for a, s in zip(arr.shape, shape)
|
|
290
|
+
)
|
|
291
|
+
if any(lo or hi for lo, hi in pad):
|
|
292
|
+
arr = np.pad(arr, pad, mode="edge")
|
|
293
|
+
return arr
|
|
294
|
+
|
|
295
|
+
|
|
241
296
|
def _segment_once(
|
|
242
297
|
block: np.ndarray, dog_dict: dict[str, Any], use_gpu: bool
|
|
243
298
|
) -> np.ndarray:
|
|
@@ -253,7 +308,16 @@ def _segment_once(
|
|
|
253
308
|
# in the per-tile timing that tile_process logs.
|
|
254
309
|
from pycudadecon import decon
|
|
255
310
|
|
|
311
|
+
before = img.shape
|
|
256
312
|
img = decon(images=img, **decon_kwargs)
|
|
313
|
+
if img.shape != before:
|
|
314
|
+
# cudaDecon returns a slightly smaller volume for some input
|
|
315
|
+
# sizes (e.g. (14,1024,1024) -> (13,1020,1020) on an edge tile).
|
|
316
|
+
# patchworks needs one label per input voxel: the halo trim and
|
|
317
|
+
# the destination slice are both derived from the tile geometry,
|
|
318
|
+
# so a shrunken result has nowhere to go and used to surface as
|
|
319
|
+
# an unreadable broadcast error from inside zarr.
|
|
320
|
+
img = _restore_shape(img, before)
|
|
257
321
|
|
|
258
322
|
if use_gpu:
|
|
259
323
|
import cupy as cp
|
|
@@ -357,3 +357,35 @@ def test_tile_process_max_workers():
|
|
|
357
357
|
arr = da.from_array(_make_image((2, 32, 32)), chunks=(1, 32, 32))
|
|
358
358
|
result = tile_process(arr, _label_fn, max_workers=1).compute()
|
|
359
359
|
assert result.shape == (2, 32, 32)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_channel_selection_respects_the_stores_axes(tmp_path):
|
|
363
|
+
"""`channel: 0` on a store with no channel axis must not slice away z.
|
|
364
|
+
|
|
365
|
+
arr[channel] was applied unconditionally, so a single-channel image
|
|
366
|
+
written as plain zyx silently lost its z axis. The array stayed valid,
|
|
367
|
+
just one dimension short, and only surfaced later as a tile count that
|
|
368
|
+
disagreed with the occupancy grid.
|
|
369
|
+
"""
|
|
370
|
+
import dask.array as da
|
|
371
|
+
import pytest
|
|
372
|
+
|
|
373
|
+
from patchworks import load_ome_zarr
|
|
374
|
+
from patchworks.plugins.ome_zarr import to_ome_zarr
|
|
375
|
+
|
|
376
|
+
vol = da.zeros((8, 64, 64), chunks=(4, 32, 32), dtype="uint16")
|
|
377
|
+
store = str(tmp_path / "zyx.zarr")
|
|
378
|
+
to_ome_zarr(vol, store, axes="zyx", n_levels=1, progress=False)
|
|
379
|
+
|
|
380
|
+
# channel 0 on a zyx store: keep the whole volume, do not index axis 0.
|
|
381
|
+
assert load_ome_zarr(store, channel=0, level=0).shape == (8, 64, 64)
|
|
382
|
+
assert load_ome_zarr(store, channel=None, level=0).shape == (8, 64, 64)
|
|
383
|
+
# A non-zero channel really is a mistake here, and is named as one.
|
|
384
|
+
with pytest.raises(ValueError, match="no channel axis"):
|
|
385
|
+
load_ome_zarr(store, channel=2, level=0)
|
|
386
|
+
|
|
387
|
+
# A czyx store still selects the channel as before.
|
|
388
|
+
vol4 = da.zeros((3, 8, 64, 64), chunks=(1, 4, 32, 32), dtype="uint16")
|
|
389
|
+
store4 = str(tmp_path / "czyx.zarr")
|
|
390
|
+
to_ome_zarr(vol4, store4, axes="czyx", n_levels=1, progress=False)
|
|
391
|
+
assert load_ome_zarr(store4, channel=1, level=0).shape == (8, 64, 64)
|
|
@@ -107,3 +107,56 @@ def test_explicit_decon_kwargs_win_over_the_calibration(monkeypatch):
|
|
|
107
107
|
assert captured["dxdata"] == 0.1 # filled from the calibration
|
|
108
108
|
assert captured["dzdata"] == 0.2
|
|
109
109
|
assert captured["dzpsf"] == 0.2
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_restore_shape_recentres_a_cropped_decon():
|
|
113
|
+
"""cudaDecon can hand back a smaller volume than it was given.
|
|
114
|
+
|
|
115
|
+
Observed on a real edge tile: (14, 1024, 1024) in, (13, 1020, 1020) out.
|
|
116
|
+
patchworks needs one label per input voxel, so the field of view has to be
|
|
117
|
+
restored before the DoG step.
|
|
118
|
+
"""
|
|
119
|
+
from patchworks.plugins.dog import _restore_shape
|
|
120
|
+
|
|
121
|
+
arr = np.arange(13 * 1020 * 1020, dtype="float32").reshape(13, 1020, 1020)
|
|
122
|
+
out = _restore_shape(arr, (14, 1024, 1024))
|
|
123
|
+
assert out.shape == (14, 1024, 1024)
|
|
124
|
+
# The original content is preserved, centred, not resampled.
|
|
125
|
+
assert np.array_equal(out[0:13, 2:1022, 2:1022], arr)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_restore_shape_handles_growth_and_exact_fit():
|
|
129
|
+
"""It must be a no-op when shapes already match, and crop when larger."""
|
|
130
|
+
from patchworks.plugins.dog import _restore_shape
|
|
131
|
+
|
|
132
|
+
same = np.ones((4, 8, 8), dtype="float32")
|
|
133
|
+
assert _restore_shape(same, (4, 8, 8)).shape == (4, 8, 8)
|
|
134
|
+
bigger = np.ones((6, 12, 12), dtype="float32")
|
|
135
|
+
assert _restore_shape(bigger, (4, 8, 8)).shape == (4, 8, 8)
|
|
136
|
+
# And a mix: one axis short, one long.
|
|
137
|
+
mixed = np.ones((2, 12), dtype="float32")
|
|
138
|
+
assert _restore_shape(mixed, (4, 8)).shape == (4, 8)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_stage_tile_rejects_a_shape_changing_function(tmp_path):
|
|
142
|
+
"""A wrong-shaped return must name the culprit, not blow up inside zarr.
|
|
143
|
+
|
|
144
|
+
This used to surface as "could not broadcast input array from shape
|
|
145
|
+
(13,1020,1020) into shape (14,1024,1024)" six frames deep in zarr's codec
|
|
146
|
+
pipeline, which says nothing about which function misbehaved.
|
|
147
|
+
"""
|
|
148
|
+
import dask.array as da
|
|
149
|
+
import pytest
|
|
150
|
+
|
|
151
|
+
from patchworks import create_stage, stage_tile
|
|
152
|
+
|
|
153
|
+
image = da.zeros((8, 32, 32), chunks=(4, 16, 16), dtype="uint16")
|
|
154
|
+
stage = str(tmp_path / "stage.zarr")
|
|
155
|
+
create_stage(stage, image.shape, (4, 16, 16))
|
|
156
|
+
|
|
157
|
+
def crops(block):
|
|
158
|
+
"""Stand-in for a deconvolution backend that trims its output."""
|
|
159
|
+
return np.zeros(tuple(s - 1 for s in block.shape), dtype="int32")
|
|
160
|
+
|
|
161
|
+
with pytest.raises(ValueError, match="one label per input voxel"):
|
|
162
|
+
stage_tile(image, crops, stage, 0, tile_shape=(4, 16, 16), overlap=2)
|
|
@@ -73,3 +73,38 @@ def test_log_tail_quotes_the_end_and_survives_a_missing_file(tmp_path):
|
|
|
73
73
|
assert "could not read" in log_tail(tmp_path / "nope.log")
|
|
74
74
|
(tmp_path / "empty.log").write_text("")
|
|
75
75
|
assert "empty" in log_tail(tmp_path / "empty.log")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_failing_step_reads_the_rule_from_snakemakes_log(tmp_path):
|
|
79
|
+
"""The failing rule must come from Snakemake, not from log timestamps.
|
|
80
|
+
|
|
81
|
+
Guessing by mtime named the wrong step in the most common case: a
|
|
82
|
+
`segment` failure leaves prepare.log the newest of the sequential steps,
|
|
83
|
+
so the failure mail quoted a log that had *succeeded*.
|
|
84
|
+
"""
|
|
85
|
+
from patchworks._notify import failing_step
|
|
86
|
+
|
|
87
|
+
log = tmp_path / "sm.log"
|
|
88
|
+
log.write_text(
|
|
89
|
+
"Building DAG of jobs...\n"
|
|
90
|
+
"[Wed Jul 30 11:17:20 2026]\n"
|
|
91
|
+
"Error in rule segment:\n"
|
|
92
|
+
" jobid: 812\n"
|
|
93
|
+
" output: seg/203.done\n"
|
|
94
|
+
" log: /w/nuclei_labels/logs/segment/203.log (check log file(s))\n"
|
|
95
|
+
)
|
|
96
|
+
assert failing_step(log) == (
|
|
97
|
+
"segment",
|
|
98
|
+
"/w/nuclei_labels/logs/segment/203.log",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_failing_step_degrades_quietly(tmp_path):
|
|
103
|
+
"""It runs inside an error handler, so it must never raise itself."""
|
|
104
|
+
from patchworks._notify import failing_step
|
|
105
|
+
|
|
106
|
+
assert failing_step(None) == (None, None)
|
|
107
|
+
assert failing_step(tmp_path / "missing.log") == (None, None)
|
|
108
|
+
clean = tmp_path / "ok.log"
|
|
109
|
+
clean.write_text("Building DAG of jobs...\n3 of 3 steps (100%) done\n")
|
|
110
|
+
assert failing_step(clean) == (None, None)
|
|
@@ -33,20 +33,30 @@ rule all:
|
|
|
33
33
|
# a local run with no scheduler, and a failure where the useful content is
|
|
34
34
|
# the step's traceback rather than the job's exit code.
|
|
35
35
|
onerror:
|
|
36
|
-
from patchworks._notify import log_tail, send
|
|
36
|
+
from patchworks._notify import failing_step, log_tail, send
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
# Snakemake's own log names the failing rule and the log it used. Picking
|
|
39
|
+
# the most recently written step log instead gets this wrong in the most
|
|
40
|
+
# common case: a `segment` failure leaves prepare.log newest among the
|
|
41
|
+
# sequential steps, so the mail quoted a log that had succeeded.
|
|
42
|
+
_rule, _log = failing_step(log)
|
|
43
|
+
if _log is None:
|
|
44
|
+
_cands = [p for p in (CONVERTLOG, OCCUPANCYLOG, PREPARELOG, MERGELOG)
|
|
45
|
+
if Path(p).exists()]
|
|
46
|
+
_cands += [str(p) for p in Path(LOGS).glob("segment/*.log")]
|
|
47
|
+
_log = max(_cands, key=lambda p: Path(p).stat().st_mtime, default=None)
|
|
48
|
+
_what = f"rule {_rule}" if _rule else "the workflow"
|
|
41
49
|
send(
|
|
42
50
|
NOTIFY_EMAIL,
|
|
43
|
-
f"[patchworks] FAILED: {
|
|
44
|
-
"The patchworks workflow failed.\n\n"
|
|
45
|
-
f" work_dir
|
|
46
|
-
f" config
|
|
47
|
-
f"
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
f"[patchworks] FAILED: {_rule or 'workflow'} - {LABEL_NAME}",
|
|
52
|
+
f"The patchworks workflow failed in {_what}.\n\n"
|
|
53
|
+
f" work_dir : {WORK}\n"
|
|
54
|
+
f" config : {LABEL_NAME}\n"
|
|
55
|
+
f" failing step : {_rule or 'unknown'}\n"
|
|
56
|
+
f" step log : {_log}\n"
|
|
57
|
+
f" snakemake log : {log}\n\n"
|
|
58
|
+
+ (f"Last lines of {_log}:\n\n{log_tail(_log)}\n"
|
|
59
|
+
if _log else "No step log was written.\n"),
|
|
50
60
|
)
|
|
51
61
|
|
|
52
62
|
|
|
@@ -117,6 +117,63 @@ def slurm_jobname_prefix(label: str) -> str:
|
|
|
117
117
|
return f"pw-{safe}"[:50]
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def _test_email(cfg: dict) -> int:
|
|
121
|
+
"""Send one test notification and report the outcome. Returns an exit code.
|
|
122
|
+
|
|
123
|
+
"No email arrived" has two very different causes that look identical from
|
|
124
|
+
the outside: the address never made it into the merged config, or it did
|
|
125
|
+
and the message was dropped somewhere downstream. This distinguishes them
|
|
126
|
+
without waiting for a multi-hour step to finish.
|
|
127
|
+
"""
|
|
128
|
+
from patchworks._notify import send, slurm_mail_extra
|
|
129
|
+
|
|
130
|
+
email = cfg.get("notify_email") or ""
|
|
131
|
+
events = cfg.get("notify_events") or ["finish", "error"]
|
|
132
|
+
if not email:
|
|
133
|
+
print(
|
|
134
|
+
"[run_multi] notify_email is empty in the merged config, so no "
|
|
135
|
+
"mail is sent by design.\n"
|
|
136
|
+
" Set it in the file `common:` points at (the per-config files "
|
|
137
|
+
"no longer carry it), then re-run this check.",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
print(f"[run_multi] notify_email : {email}")
|
|
143
|
+
print(f"[run_multi] notify_events : {events}")
|
|
144
|
+
print(
|
|
145
|
+
f"[run_multi] SLURM per-job : sbatch {slurm_mail_extra(email, events)}"
|
|
146
|
+
)
|
|
147
|
+
print(
|
|
148
|
+
"[run_multi] NOTE: convert, occupancy and merge send per-job mail; "
|
|
149
|
+
"segment does not (one job per tile batch would mean hundreds)."
|
|
150
|
+
)
|
|
151
|
+
ok = send(
|
|
152
|
+
email,
|
|
153
|
+
"[patchworks] test notification",
|
|
154
|
+
"This is a patchworks test message.\n\n"
|
|
155
|
+
"If you received it, the workflow's own success/failure mail will "
|
|
156
|
+
"reach you too.\n\n"
|
|
157
|
+
"Per-job start/finish mail is sent by SLURM itself, not by this "
|
|
158
|
+
"path, so it can still be blocked separately -- verify with:\n"
|
|
159
|
+
" scontrol show job <jobid> | grep -i mail\n",
|
|
160
|
+
)
|
|
161
|
+
if ok:
|
|
162
|
+
print(
|
|
163
|
+
"[run_multi] handed to a local mail transport. If nothing "
|
|
164
|
+
"arrives, the message was accepted and then dropped further "
|
|
165
|
+
"along -- ask the cluster admins about outbound mail."
|
|
166
|
+
)
|
|
167
|
+
return 0
|
|
168
|
+
print(
|
|
169
|
+
"[run_multi] no local mail transport accepted the message (see the "
|
|
170
|
+
"warning above). SLURM's own per-job mail may still work, since the "
|
|
171
|
+
"controller sends that, not this host.",
|
|
172
|
+
file=sys.stderr,
|
|
173
|
+
)
|
|
174
|
+
return 1
|
|
175
|
+
|
|
176
|
+
|
|
120
177
|
def _run(cmd: list[str], workflow_dir: Path) -> int:
|
|
121
178
|
print(f"[run_multi] $ {' '.join(cmd)}", flush=True)
|
|
122
179
|
return subprocess.run(cmd, cwd=workflow_dir).returncode
|
|
@@ -252,6 +309,16 @@ def main() -> None:
|
|
|
252
309
|
"died: the lock is only released on a clean exit."
|
|
253
310
|
),
|
|
254
311
|
)
|
|
312
|
+
parser.add_argument(
|
|
313
|
+
"--test-email",
|
|
314
|
+
action="store_true",
|
|
315
|
+
help=(
|
|
316
|
+
"send one test message to the configured notify_email and report "
|
|
317
|
+
"what happened, then exit. Separates 'the address never reached "
|
|
318
|
+
"the config' from 'the mail was rejected downstream', which "
|
|
319
|
+
"otherwise look identical: no email either way."
|
|
320
|
+
),
|
|
321
|
+
)
|
|
255
322
|
args = parser.parse_args()
|
|
256
323
|
|
|
257
324
|
workflow_dir = Path(__file__).resolve().parent.parent
|
|
@@ -270,6 +337,9 @@ def main() -> None:
|
|
|
270
337
|
common_path = _resolve(workflow_dir, common_path) if common_path else None
|
|
271
338
|
common_cfg = _load_yaml(common_path) if common_path else {}
|
|
272
339
|
seg_cfgs = [{**common_cfg, **_load_yaml(p)} for p in seg_config_paths]
|
|
340
|
+
if args.test_email:
|
|
341
|
+
sys.exit(_test_email(seg_cfgs[0]))
|
|
342
|
+
|
|
273
343
|
work_dir = _validate_configs(seg_config_paths, seg_cfgs)
|
|
274
344
|
image_store = f"{work_dir}/image.zarr"
|
|
275
345
|
# Shared by every config, hence keyed on the image and level, not on a
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|