patchworks 2.4.0__tar.gz → 2.5.1__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.4.0 → patchworks-2.5.1}/PKG-INFO +1 -1
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/snakemake.md +40 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_distributed.py +16 -0
- patchworks-2.5.1/src/patchworks/_notify.py +157 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/plugins/dog.py +64 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_dog.py +53 -0
- patchworks-2.5.1/tests/test_notify.py +75 -0
- patchworks-2.5.1/workflow/Snakefile +64 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/common.yaml +9 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/config.yaml +5 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/rules/common.smk +22 -1
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/rules/convert.smk +4 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/rules/merge.smk +2 -0
- patchworks-2.4.0/workflow/Snakefile +0 -29
- {patchworks-2.4.0 → patchworks-2.5.1}/.github/workflows/docs.yml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/.github/workflows/lint.yml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/.github/workflows/release.yml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/.gitignore +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/LICENSE +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/README.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/cliff.toml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/chunks.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/cluster.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/io.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/postprocess.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/relabel.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/api/tile_process.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/assets/logo.png +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/custom.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/custom_method.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/dog.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/dog.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/stardist.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/getting_started.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/label_relations.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/measurements.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/merging.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/performance.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/guide/tiling.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/docs/index.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/mkdocs.yml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/pyproject.toml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/__init__.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_core.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_io.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_merge.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_occupancy.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_progress.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/_relations.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/src/patchworks/plugins/ome_zarr.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_allocation.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_core.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_distributed.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_gpu.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_napari.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_occupancy.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_postprocess.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_progress.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_relations.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/tests/test_run_multi.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/README.md +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/config/multi.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/pixi.toml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/profile/slurm/config.yaml +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/rules/segment.smk +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/build_occupancy.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/convert.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/merge.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/prepare_tiles.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/run_multi.py +0 -0
- {patchworks-2.4.0 → patchworks-2.5.1}/workflow/scripts/segment_tile.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.1
|
|
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
|
|
@@ -442,6 +442,46 @@ cyto_labels` and `cilia_labels -> nuclei_labels`) so you can use whichever
|
|
|
442
442
|
fits a given dataset. See `config/config_cilia.yaml`. Its deconvolution step
|
|
443
443
|
needs `pip install "patchworks[dog]"` in the segment jobs' environment.
|
|
444
444
|
|
|
445
|
+
## Email notifications
|
|
446
|
+
|
|
447
|
+
Set an address and the workflow mails you when the long steps finish or fail:
|
|
448
|
+
|
|
449
|
+
```yaml
|
|
450
|
+
# config/common.yaml (or config/config.yaml for a single-config run)
|
|
451
|
+
notify_email: "you@unibas.ch"
|
|
452
|
+
notify_events: ["finish", "error"] # any of: start, finish, error
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Leave `notify_email` empty (the default) and nothing is sent.
|
|
456
|
+
|
|
457
|
+
Per-job mail is **SLURM's own** `--mail-type`, not a message sent from inside
|
|
458
|
+
the job. That matters: the controller sends it, so it still arrives when a job
|
|
459
|
+
is OOM-killed or cancelled by the scheduler — exactly the failures worth
|
|
460
|
+
hearing about, and exactly the ones a notification sent from within the job
|
|
461
|
+
would miss.
|
|
462
|
+
|
|
463
|
+
It is applied to the long single-job steps only — `convert`, `occupancy` and
|
|
464
|
+
`merge`. `segment` is deliberately excluded: there is one job per tile batch,
|
|
465
|
+
so a thousand-tile run would mean hundreds of messages.
|
|
466
|
+
|
|
467
|
+
On top of that, the workflow itself sends:
|
|
468
|
+
|
|
469
|
+
| When | Mail |
|
|
470
|
+
| --- | --- |
|
|
471
|
+
| The run fails | subject `[patchworks] FAILED: <label_name>`, with the last 40 lines of the failing step's log — usually the traceback itself |
|
|
472
|
+
| The run succeeds | subject `[patchworks] done: <label_name>`, with the output label path |
|
|
473
|
+
|
|
474
|
+
These cover what SLURM cannot: a local run with no scheduler at all, and
|
|
475
|
+
failures where the useful content is the Python traceback rather than an exit
|
|
476
|
+
code.
|
|
477
|
+
|
|
478
|
+
!!! note "Delivery is best-effort, by design"
|
|
479
|
+
A notification can never fail a run. If no local `sendmail` exists and no
|
|
480
|
+
SMTP server answers on localhost, the failure is logged as a warning and
|
|
481
|
+
the pipeline carries on — a six-hour segmentation that worked must not be
|
|
482
|
+
reported as failed because a mail host was down. If you get the warning
|
|
483
|
+
but no mail, ask your cluster admins which relay host to use.
|
|
484
|
+
|
|
445
485
|
## Measurements
|
|
446
486
|
|
|
447
487
|
See [Measurements](measurements.md) for computing area/centroid/intensity
|
|
@@ -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)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Best-effort email notification.
|
|
2
|
+
|
|
3
|
+
Used for the events SLURM cannot report on its own: the end of a whole
|
|
4
|
+
multi-config run, and failures in a local (non-SLURM) run. Per-job
|
|
5
|
+
start/finish mail on a cluster is left to SLURM's own ``--mail-type``, which
|
|
6
|
+
is delivered by the controller and does not depend on a compute node being
|
|
7
|
+
able to reach an MTA.
|
|
8
|
+
|
|
9
|
+
Every function here is best-effort by design: a notification that cannot be
|
|
10
|
+
delivered must never fail a pipeline that otherwise succeeded, and must never
|
|
11
|
+
turn a real error into a confusing one about email.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import shutil
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
from email.message import EmailMessage
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Union
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Lines of a failing step's log to quote in an error mail. Enough to carry a
|
|
27
|
+
# traceback, short enough that the mail stays readable on a phone.
|
|
28
|
+
LOG_TAIL_LINES = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log_tail(path: Union[str, Path], lines: int = LOG_TAIL_LINES) -> str:
|
|
32
|
+
"""Return the last *lines* of a log file, or a note if unreadable."""
|
|
33
|
+
try:
|
|
34
|
+
content = Path(path).read_text(errors="replace").splitlines()
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
return f"(could not read {path}: {exc})"
|
|
37
|
+
if not content:
|
|
38
|
+
return f"({path} is empty)"
|
|
39
|
+
return "\n".join(content[-lines:])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def send(
|
|
43
|
+
to: Union[str, None],
|
|
44
|
+
subject: str,
|
|
45
|
+
body: str,
|
|
46
|
+
*,
|
|
47
|
+
sender: Union[str, None] = None,
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""Send *body* to *to*, returning whether it went out.
|
|
50
|
+
|
|
51
|
+
Tries a local ``sendmail`` first (what a cluster node normally has), then
|
|
52
|
+
an SMTP server on localhost. Never raises.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
to : str or None
|
|
57
|
+
Recipient. ``None`` or empty disables the notification entirely, which
|
|
58
|
+
is the default state -- no address configured, no mail, no error.
|
|
59
|
+
subject : str
|
|
60
|
+
Subject line.
|
|
61
|
+
body : str
|
|
62
|
+
Plain-text body.
|
|
63
|
+
sender : str, optional
|
|
64
|
+
From address. Defaults to ``patchworks@<hostname>``.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
bool
|
|
69
|
+
True when the message was handed to a transport.
|
|
70
|
+
"""
|
|
71
|
+
if not to:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
msg = EmailMessage()
|
|
75
|
+
msg["To"] = to
|
|
76
|
+
msg["From"] = sender or f"patchworks@{socket.getfqdn()}"
|
|
77
|
+
msg["Subject"] = subject
|
|
78
|
+
msg.set_content(body)
|
|
79
|
+
|
|
80
|
+
sendmail = shutil.which("sendmail") or shutil.which(
|
|
81
|
+
"sendmail", path="/usr/sbin:/usr/lib"
|
|
82
|
+
)
|
|
83
|
+
if sendmail:
|
|
84
|
+
try:
|
|
85
|
+
subprocess.run(
|
|
86
|
+
[sendmail, "-t", "-oi"],
|
|
87
|
+
input=msg.as_bytes(),
|
|
88
|
+
check=True,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
timeout=30,
|
|
91
|
+
)
|
|
92
|
+
return True
|
|
93
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"sendmail failed (%s); trying SMTP on localhost", exc
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import smtplib
|
|
100
|
+
|
|
101
|
+
with smtplib.SMTP("localhost", timeout=30) as smtp:
|
|
102
|
+
smtp.send_message(msg)
|
|
103
|
+
return True
|
|
104
|
+
except (OSError, Exception) as exc: # noqa: BLE001 - never fail the run
|
|
105
|
+
logger.warning(
|
|
106
|
+
"could not send notification to %s (%s). The run itself is "
|
|
107
|
+
"unaffected; set notify_email to '' to silence this.",
|
|
108
|
+
to,
|
|
109
|
+
exc,
|
|
110
|
+
)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def slurm_mail_extra(
|
|
115
|
+
email: Union[str, None], events: Union[list, tuple, None] = None
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Build the ``slurm_extra`` fragment for per-job mail.
|
|
118
|
+
|
|
119
|
+
SLURM's own ``--mail-type`` is used rather than sending from inside the
|
|
120
|
+
job: the controller delivers it, so it still arrives when the job is
|
|
121
|
+
killed by the OOM reaper or the scheduler -- exactly the cases worth
|
|
122
|
+
hearing about, and exactly the ones an in-job notification misses.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
email : str or None
|
|
127
|
+
Recipient; empty/None yields an empty string (no mail configured).
|
|
128
|
+
events : sequence of str, optional
|
|
129
|
+
Any of ``"start"``, ``"finish"``, ``"error"``. Defaults to finish and
|
|
130
|
+
error -- a BEGIN mail per job is rarely worth the inbox.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
str
|
|
135
|
+
Something like ``--mail-type=END,FAIL --mail-user=me@example.org``,
|
|
136
|
+
or ``""`` when no address is configured.
|
|
137
|
+
|
|
138
|
+
Examples
|
|
139
|
+
--------
|
|
140
|
+
>>> slurm_mail_extra("me@example.org", ["error"])
|
|
141
|
+
'--mail-type=FAIL --mail-user=me@example.org'
|
|
142
|
+
>>> slurm_mail_extra(None)
|
|
143
|
+
''
|
|
144
|
+
"""
|
|
145
|
+
if not email:
|
|
146
|
+
return ""
|
|
147
|
+
mapping = {"start": "BEGIN", "finish": "END", "error": "FAIL"}
|
|
148
|
+
chosen = list(events) if events else ["finish", "error"]
|
|
149
|
+
unknown = sorted(set(chosen) - set(mapping))
|
|
150
|
+
if unknown:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"unknown notify_events {unknown}; use any of "
|
|
153
|
+
f"{sorted(mapping)} (they map to SLURM's BEGIN/END/FAIL)"
|
|
154
|
+
)
|
|
155
|
+
# Keep SLURM's own order, not the config's, so the string is stable.
|
|
156
|
+
types = [mapping[k] for k in ("start", "finish", "error") if k in chosen]
|
|
157
|
+
return f"--mail-type={','.join(types)} --mail-user={email}"
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Tests for email notification.
|
|
2
|
+
|
|
3
|
+
The governing rule: a notification must never be able to fail a run. Delivery
|
|
4
|
+
is best-effort on someone else's mail infrastructure, and a pipeline that
|
|
5
|
+
segmented correctly must not report failure because an SMTP host was down.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from patchworks._notify import log_tail, send, slurm_mail_extra
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_no_address_means_no_mail_and_no_error():
|
|
14
|
+
"""The default state is "unconfigured", which must be completely silent."""
|
|
15
|
+
assert send(None, "subject", "body") is False
|
|
16
|
+
assert send("", "subject", "body") is False
|
|
17
|
+
assert slurm_mail_extra(None) == ""
|
|
18
|
+
assert slurm_mail_extra("") == ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_slurm_mail_extra_maps_events_to_slurm_types():
|
|
22
|
+
"""Config words map to SLURM's BEGIN/END/FAIL, in a stable order."""
|
|
23
|
+
assert (
|
|
24
|
+
slurm_mail_extra("me@x.org")
|
|
25
|
+
== "--mail-type=END,FAIL --mail-user=me@x.org"
|
|
26
|
+
)
|
|
27
|
+
assert (
|
|
28
|
+
slurm_mail_extra("me@x.org", ["error"])
|
|
29
|
+
== "--mail-type=FAIL --mail-user=me@x.org"
|
|
30
|
+
)
|
|
31
|
+
# Order comes from SLURM's lifecycle, not from however the config listed it.
|
|
32
|
+
assert slurm_mail_extra(
|
|
33
|
+
"me@x.org", ["error", "start", "finish"]
|
|
34
|
+
) == slurm_mail_extra("me@x.org", ["start", "finish", "error"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_a_misspelled_event_is_rejected_up_front():
|
|
38
|
+
"""Better a named error before submission than silently no mail.
|
|
39
|
+
|
|
40
|
+
The value ends up in an sbatch argument, where a typo would otherwise
|
|
41
|
+
either be ignored or fail every job at submission time.
|
|
42
|
+
"""
|
|
43
|
+
with pytest.raises(ValueError, match="notify_events"):
|
|
44
|
+
slurm_mail_extra("me@x.org", ["finished"]) # not "finish"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_delivery_failure_is_swallowed(monkeypatch):
|
|
48
|
+
"""An unreachable transport returns False rather than raising.
|
|
49
|
+
|
|
50
|
+
This is the property that keeps a mail problem from turning a successful
|
|
51
|
+
six-hour run into a failed one.
|
|
52
|
+
"""
|
|
53
|
+
import patchworks._notify as notify
|
|
54
|
+
|
|
55
|
+
monkeypatch.setattr(notify.shutil, "which", lambda *a, **k: None)
|
|
56
|
+
|
|
57
|
+
def _boom(*args, **kwargs):
|
|
58
|
+
raise OSError("no route to host")
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr("smtplib.SMTP", _boom)
|
|
61
|
+
assert send("me@x.org", "subject", "body") is False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_log_tail_quotes_the_end_and_survives_a_missing_file(tmp_path):
|
|
65
|
+
"""The tail is what makes a failure mail actionable."""
|
|
66
|
+
p = tmp_path / "step.log"
|
|
67
|
+
p.write_text("\n".join(f"line {i}" for i in range(200)))
|
|
68
|
+
tail = log_tail(p, lines=5)
|
|
69
|
+
assert tail.splitlines() == [f"line {i}" for i in range(195, 200)]
|
|
70
|
+
|
|
71
|
+
# A missing or empty log must produce a note, not an exception -- this
|
|
72
|
+
# runs inside an error handler, where raising would mask the real failure.
|
|
73
|
+
assert "could not read" in log_tail(tmp_path / "nope.log")
|
|
74
|
+
(tmp_path / "empty.log").write_text("")
|
|
75
|
+
assert "empty" in log_tail(tmp_path / "empty.log")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# patchworks Snakemake workflow.
|
|
2
|
+
#
|
|
3
|
+
# convert ──▶ prepare (checkpoint) ──▶ segment {tile} ──▶ merge
|
|
4
|
+
# one GPU job per tile
|
|
5
|
+
#
|
|
6
|
+
# Run locally:
|
|
7
|
+
# snakemake --cores 8 --configfile config/config.yaml
|
|
8
|
+
# Run on SLURM (one GPU job per tile, many GPUs in parallel):
|
|
9
|
+
# snakemake --workflow-profile profile/slurm --configfile config/config.yaml
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
configfile: "config/config.yaml"
|
|
15
|
+
|
|
16
|
+
include: "rules/common.smk"
|
|
17
|
+
include: "rules/convert.smk"
|
|
18
|
+
include: "rules/segment.smk"
|
|
19
|
+
include: "rules/merge.smk"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Runs on the submit host (has network) — never submitted to an offline GPU node.
|
|
23
|
+
localrules:
|
|
24
|
+
fetch_model,
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
rule all:
|
|
28
|
+
input:
|
|
29
|
+
f"{RUN}/labels.done",
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Workflow-level notifications, covering what SLURM's per-job mail cannot:
|
|
33
|
+
# a local run with no scheduler, and a failure where the useful content is
|
|
34
|
+
# the step's traceback rather than the job's exit code.
|
|
35
|
+
onerror:
|
|
36
|
+
from patchworks._notify import log_tail, send
|
|
37
|
+
|
|
38
|
+
_failed = [p for p in (CONVERTLOG, OCCUPANCYLOG, PREPARELOG, MERGELOG)
|
|
39
|
+
if Path(p).exists()]
|
|
40
|
+
_newest = max(_failed, key=lambda p: Path(p).stat().st_mtime, default=None)
|
|
41
|
+
send(
|
|
42
|
+
NOTIFY_EMAIL,
|
|
43
|
+
f"[patchworks] FAILED: {LABEL_NAME} in {WORK}",
|
|
44
|
+
"The patchworks workflow failed.\n\n"
|
|
45
|
+
f" work_dir : {WORK}\n"
|
|
46
|
+
f" config : {LABEL_NAME}\n"
|
|
47
|
+
f" log : {_newest}\n\n"
|
|
48
|
+
+ (f"Last lines of {_newest}:\n\n{log_tail(_newest)}\n"
|
|
49
|
+
if _newest else "No step log was written.\n"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
onsuccess:
|
|
54
|
+
if "finish" in NOTIFY_EVENTS:
|
|
55
|
+
from patchworks._notify import send
|
|
56
|
+
|
|
57
|
+
send(
|
|
58
|
+
NOTIFY_EMAIL,
|
|
59
|
+
f"[patchworks] done: {LABEL_NAME} in {WORK}",
|
|
60
|
+
f"The patchworks workflow finished successfully.\n\n"
|
|
61
|
+
f" work_dir : {WORK}\n"
|
|
62
|
+
f" config : {LABEL_NAME}\n"
|
|
63
|
+
f" labels : {IMAGE}/labels/{LABEL_NAME}\n",
|
|
64
|
+
)
|
|
@@ -40,3 +40,12 @@ tiles_per_job: 4
|
|
|
40
40
|
# --- merge ---
|
|
41
41
|
sequential_labels: true
|
|
42
42
|
merge_workers: null
|
|
43
|
+
|
|
44
|
+
# --- notifications (optional) ---
|
|
45
|
+
# Leave empty for no email. Per-job mail is sent by SLURM itself for the long
|
|
46
|
+
# single-job steps (convert, occupancy, merge), so it still arrives when a job
|
|
47
|
+
# is OOM-killed. `segment` is deliberately excluded: one job per tile batch
|
|
48
|
+
# would mean hundreds of messages.
|
|
49
|
+
notify_email: ""
|
|
50
|
+
# Any of: start, finish, error (SLURM BEGIN, END, FAIL)
|
|
51
|
+
notify_events: ["finish", "error"]
|
|
@@ -80,3 +80,8 @@ sequential_labels: true # renumber merged labels to a contiguous 1..N range
|
|
|
80
80
|
# limit and cpus_per_task, not the node's totals. The merge logs what it
|
|
81
81
|
# detected, so you can compare that line against `seff <jobid>`.
|
|
82
82
|
merge_workers: null
|
|
83
|
+
|
|
84
|
+
# --- notifications (optional) ---
|
|
85
|
+
# Email on step completion/failure. Empty = off. See docs/guide/snakemake.md.
|
|
86
|
+
notify_email: ""
|
|
87
|
+
notify_events: ["finish", "error"]
|
|
@@ -36,7 +36,9 @@ STAGE_OK = f"{STAGE}.done"
|
|
|
36
36
|
# wiped the previous one's output -- by the time a run finished, only the last
|
|
37
37
|
# step's log survived and a failure earlier on left nothing to read.
|
|
38
38
|
LOGS = f"{RUN}/logs"
|
|
39
|
-
|
|
39
|
+
# convert and occupancy are shared by every config, so their logs belong
|
|
40
|
+
# beside the image, not under whichever config happened to run phase A.
|
|
41
|
+
CONVERTLOG = f"{WORK}/logs/convert.log"
|
|
40
42
|
PREPARELOG = f"{LOGS}/prepare.log"
|
|
41
43
|
MERGELOG = f"{LOGS}/merge.log"
|
|
42
44
|
|
|
@@ -57,3 +59,22 @@ def batch_done(wildcards):
|
|
|
57
59
|
tiles = checkpoints.prepare.get().output.tiles
|
|
58
60
|
manifest = json.loads(Path(tiles).read_text())
|
|
59
61
|
return [f"{RUN}/seg/{i}.done" for i in range(len(manifest["batches"]))]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- notifications -----------------------------------------------------
|
|
65
|
+
# Per-job mail is SLURM's own --mail-type: the controller sends it, so it
|
|
66
|
+
# still arrives when a job is OOM-killed or cancelled by the scheduler --
|
|
67
|
+
# the cases most worth hearing about, and the ones a notification sent from
|
|
68
|
+
# inside the job would miss. Empty when no address is configured.
|
|
69
|
+
#
|
|
70
|
+
# Deliberately NOT applied to `segment`: there is one job per tile batch, so
|
|
71
|
+
# a thousand-tile run would mean hundreds of emails.
|
|
72
|
+
NOTIFY_EMAIL = config.get("notify_email") or ""
|
|
73
|
+
NOTIFY_EVENTS = config.get("notify_events") or ["finish", "error"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def notify_extra(*_args, **_kwargs):
|
|
77
|
+
"""slurm_extra fragment requesting mail for this job."""
|
|
78
|
+
from patchworks._notify import slurm_mail_extra
|
|
79
|
+
|
|
80
|
+
return slurm_mail_extra(NOTIFY_EMAIL, NOTIFY_EVENTS)
|
|
@@ -4,6 +4,8 @@ rule convert:
|
|
|
4
4
|
output:
|
|
5
5
|
# marker file inside the store; existence => skip re-conversion.
|
|
6
6
|
IMAGE_OK,
|
|
7
|
+
resources:
|
|
8
|
+
slurm_extra=notify_extra,
|
|
7
9
|
log:
|
|
8
10
|
CONVERTLOG,
|
|
9
11
|
script:
|
|
@@ -18,6 +20,8 @@ rule occupancy:
|
|
|
18
20
|
IMAGE_OK,
|
|
19
21
|
output:
|
|
20
22
|
OCCUPANCY_OK,
|
|
23
|
+
resources:
|
|
24
|
+
slurm_extra=notify_extra,
|
|
21
25
|
log:
|
|
22
26
|
OCCUPANCYLOG,
|
|
23
27
|
script:
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# patchworks Snakemake workflow.
|
|
2
|
-
#
|
|
3
|
-
# convert ──▶ prepare (checkpoint) ──▶ segment {tile} ──▶ merge
|
|
4
|
-
# one GPU job per tile
|
|
5
|
-
#
|
|
6
|
-
# Run locally:
|
|
7
|
-
# snakemake --cores 8 --configfile config/config.yaml
|
|
8
|
-
# Run on SLURM (one GPU job per tile, many GPUs in parallel):
|
|
9
|
-
# snakemake --workflow-profile profile/slurm --configfile config/config.yaml
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
configfile: "config/config.yaml"
|
|
15
|
-
|
|
16
|
-
include: "rules/common.smk"
|
|
17
|
-
include: "rules/convert.smk"
|
|
18
|
-
include: "rules/segment.smk"
|
|
19
|
-
include: "rules/merge.smk"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Runs on the submit host (has network) — never submitted to an offline GPU node.
|
|
23
|
-
localrules:
|
|
24
|
-
fetch_model,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
rule all:
|
|
28
|
-
input:
|
|
29
|
-
f"{RUN}/labels.done",
|
|
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
|