patchworks 2.3.0__tar.gz → 2.4.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.
- {patchworks-2.3.0 → patchworks-2.4.0}/PKG-INFO +1 -1
- {patchworks-2.3.0 → patchworks-2.4.0}/cliff.toml +3 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/dog.md +16 -11
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/performance.md +3 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/snakemake.md +2 -1
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_merge.py +31 -16
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_occupancy.py +10 -3
- patchworks-2.4.0/src/patchworks/_progress.py +184 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/ome_zarr.py +34 -29
- patchworks-2.4.0/tests/test_progress.py +60 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_run_multi.py +37 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/profile/slurm/config.yaml +10 -3
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/common.smk +14 -3
- patchworks-2.4.0/workflow/rules/convert.smk +24 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/merge.smk +1 -1
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/segment.smk +5 -1
- patchworks-2.4.0/workflow/scripts/build_occupancy.py +33 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/convert.py +4 -1
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/merge.py +3 -2
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/run_multi.py +13 -26
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/segment_tile.py +18 -3
- patchworks-2.3.0/workflow/rules/convert.smk +0 -10
- {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/docs.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/lint.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/release.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/.gitignore +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/.markdownlint-cli2.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/LICENSE +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/README.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/chunks.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/cluster.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/io.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/merge_tile_labels.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/cellpose.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/dog.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/napari.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/ome_zarr.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/postprocess.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/relabel.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/tile_process.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/assets/logo.png +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_2d.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_2d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_3d.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_3d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/custom.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/custom_method.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/standalone_merge.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/stardist.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/stardist_2d.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/getting_started.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/custom_segmentation.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/gpu_distributed.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/label_relations.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/measurements.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/merging.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/ome_zarr_napari.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/pitfalls.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/skip_empty.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/tiling.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/docs/index.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/mkdocs.yml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/pyproject.toml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/__init__.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_chunks.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_cluster.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_core.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_distributed.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_gpu.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_io.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_postprocess.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_relabel.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_relations.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/__init__.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/cellpose.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/napari.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_allocation.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_core.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_distributed.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_dog.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_gpu.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_napari.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_occupancy.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_ome_zarr.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_postprocess.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_relations.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/README.md +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/Snakefile +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/common.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_cilia.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_cyto.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_nuclei.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/multi.yaml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/pixi.toml +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/_pw.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/fetch_model.py +0 -0
- {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/prepare_tiles.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchworks
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -32,7 +32,10 @@ commit_preprocessors = [
|
|
|
32
32
|
]
|
|
33
33
|
commit_parsers = [
|
|
34
34
|
# Merge commits are noise in a changelog — the merged commits are already listed.
|
|
35
|
+
# Anchored to the message start so a commit that merely mentions "merge dev
|
|
36
|
+
# ... main" in its own text isn't swept up too.
|
|
35
37
|
{ message = "^Merge ", skip = true },
|
|
38
|
+
{ message = "(?i)^chore:\\s*merge dev.*main", skip = true },
|
|
36
39
|
{ message = "^feat", group = "✨ Features" },
|
|
37
40
|
{ message = "^fix", group = "🐛 Bug Fixes" },
|
|
38
41
|
{ message = "^perf", group = "⚡ Performance" },
|
|
@@ -147,11 +147,13 @@ example above — and the segment job deconvolves each tile with
|
|
|
147
147
|
`pycudadecon` before running the DoG detector:
|
|
148
148
|
|
|
149
149
|
```yaml
|
|
150
|
-
# config/config_cilia.yaml (excerpt)
|
|
150
|
+
# config/config_cilia.yaml (excerpt) — only what differs from common.yaml,
|
|
151
|
+
# which supplies the input, work_dir, tile_shape and skip_empty
|
|
151
152
|
channel: 2
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
# Per-axis halo [z, y, x], covering the PSF support (decon) + the DoG's
|
|
154
|
+
# high_sigma. A scalar 30 would expand a [16, 1024, 1024] tile to 5.3x the
|
|
155
|
+
# voxels it keeps, nearly all of it wasted z.
|
|
156
|
+
overlap: [8, 30, 30]
|
|
155
157
|
|
|
156
158
|
method: "custom"
|
|
157
159
|
label_name: "cilia_labels"
|
|
@@ -185,11 +187,11 @@ in `dxdata`/`dxpsf` from X/Y and `dzdata`/`dzpsf` from Z. Set any of them in
|
|
|
185
187
|
of the calibration in its config. If the store is uncalibrated the
|
|
186
188
|
workflow says so and passes nothing.
|
|
187
189
|
|
|
188
|
-
Run it exactly like a Cellpose config
|
|
190
|
+
Run it exactly like a Cellpose config — the shared settings come from
|
|
191
|
+
`config/common.yaml`, merged in ahead of this one:
|
|
189
192
|
|
|
190
193
|
```bash
|
|
191
|
-
python -m snakemake --workflow-profile profile/slurm
|
|
192
|
-
--configfile config/config_cilia.yaml
|
|
194
|
+
python -m snakemake --workflow-profile profile/slurm --configfile config/common.yaml config/config_cilia.yaml
|
|
193
195
|
```
|
|
194
196
|
|
|
195
197
|
Checklist specific to this config:
|
|
@@ -206,10 +208,13 @@ Checklist specific to this config:
|
|
|
206
208
|
a thin intensity/threshold halo isn't enough once deconvolution is in the
|
|
207
209
|
loop.
|
|
208
210
|
- **`skip_empty`:** the `prepare` rule (`workflow/scripts/prepare_tiles.py`)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
builds a max-pooled occupancy map and reduces it over each tile's **full**
|
|
212
|
+
footprint (`build_occupancy_map` + `tile_occupancy`) before submitting any
|
|
213
|
+
`segment` jobs, regardless of `method`. Cilia are small and often sit near
|
|
214
|
+
a tile's edge, which is precisely where the older centred-window preview
|
|
215
|
+
could miss them — this decides every tile exactly. No extra config needed
|
|
216
|
+
beyond `skip_empty: true` (the default), and the map is built once and
|
|
217
|
+
shared by every config against that store.
|
|
213
218
|
- Run alongside `config_cyto.yaml`/`config_nuclei.yaml` via `config/multi.yaml`
|
|
214
219
|
to also get the cilia→cell/nucleus relation — see *Relating cilia to their
|
|
215
220
|
cell*, below.
|
|
@@ -74,6 +74,9 @@ extra read-back of the staged data.
|
|
|
74
74
|
|
|
75
75
|
- `tile_shape="auto"` sizes tiles to free RAM (or VRAM with `use_gpu=True`).
|
|
76
76
|
- `skip_empty=True` with `estimate_empty_tiles()` skips background tiles.
|
|
77
|
+
That preview samples a centred window, so use `build_occupancy_map()` +
|
|
78
|
+
`tile_occupancy()` when the result becomes an authoritative skip list —
|
|
79
|
+
they are exact, and it is what the Snakemake workflow uses.
|
|
77
80
|
- A Dask **distributed** cluster (`make_local_cluster`) parallelises across
|
|
78
81
|
workers/GPUs; patchworks then defers concurrency to the cluster.
|
|
79
82
|
|
|
@@ -520,7 +520,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
|
|
|
520
520
|
| `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
|
|
521
521
|
| Reading the input fails | install the matching reader (`patchworks[imaris]`/`[bioio]` + a `bioio-*`) |
|
|
522
522
|
| Out of GPU memory | smaller `tile_shape`, or `do_3D: false` |
|
|
523
|
-
| A job fails with an empty SLURM log | read `logs/segment/<
|
|
523
|
+
| A job fails with an empty SLURM log | read the step's own log — `logs/convert.log`, `logs/prepare.log`, `logs/segment/<batch>.log`, `logs/merge.log` — the real traceback is there |
|
|
524
|
+
| A long step looks hung | every step logs progress (`… 4,200/8,064 (52%) after 31m, ~28m left`) roughly once a minute; `tail -f` the step's log above |
|
|
524
525
|
| Very slow | confirm GPU is used (`nvidia-smi`); try 2-D or a lower `level` |
|
|
525
526
|
|
|
526
527
|
## How it works (for the curious)
|
|
@@ -23,7 +23,6 @@ import logging
|
|
|
23
23
|
import os
|
|
24
24
|
import tempfile
|
|
25
25
|
from concurrent.futures import ThreadPoolExecutor
|
|
26
|
-
from contextlib import nullcontext as _nullcontext
|
|
27
26
|
from itertools import product as _iproduct
|
|
28
27
|
from multiprocessing import Pool as _Pool
|
|
29
28
|
from pathlib import Path
|
|
@@ -35,11 +34,7 @@ import zarr
|
|
|
35
34
|
|
|
36
35
|
from ._chunks import cpu_allocation
|
|
37
36
|
from ._io import zarr_compressor_kwargs
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
from tqdm.auto import tqdm as _tqdm
|
|
41
|
-
except ImportError:
|
|
42
|
-
_tqdm = None
|
|
37
|
+
from ._progress import track
|
|
43
38
|
|
|
44
39
|
logger = logging.getLogger(__name__)
|
|
45
40
|
|
|
@@ -165,6 +160,7 @@ def _scan_touching_pairs(
|
|
|
165
160
|
label_offsets: "np.ndarray | None" = None,
|
|
166
161
|
n_workers: int = 1,
|
|
167
162
|
has_labels: "np.ndarray | None" = None,
|
|
163
|
+
progress: bool = False,
|
|
168
164
|
) -> np.ndarray:
|
|
169
165
|
"""Scan chunk-boundary slabs; return (N, 2) int64 array of touching pairs.
|
|
170
166
|
|
|
@@ -259,12 +255,26 @@ def _scan_touching_pairs(
|
|
|
259
255
|
|
|
260
256
|
nw = max(1, min(n_workers, len(tasks)))
|
|
261
257
|
if nw <= 1:
|
|
262
|
-
results =
|
|
258
|
+
results = list(
|
|
259
|
+
track(
|
|
260
|
+
(_one(t) for t in tasks),
|
|
261
|
+
"scan boundaries",
|
|
262
|
+
len(tasks),
|
|
263
|
+
enabled=progress,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
263
266
|
else:
|
|
264
267
|
# Reads and decompression release the GIL, so threads scale here and
|
|
265
268
|
# nothing has to be pickled across processes.
|
|
266
269
|
with ThreadPoolExecutor(max_workers=nw) as pool:
|
|
267
|
-
results = list(
|
|
270
|
+
results = list(
|
|
271
|
+
track(
|
|
272
|
+
pool.map(_one, tasks),
|
|
273
|
+
"scan boundaries",
|
|
274
|
+
len(tasks),
|
|
275
|
+
enabled=progress,
|
|
276
|
+
)
|
|
277
|
+
)
|
|
268
278
|
|
|
269
279
|
all_pairs = [r for r in results if r is not None]
|
|
270
280
|
if not all_pairs:
|
|
@@ -609,6 +619,7 @@ def zarr_native_merge(
|
|
|
609
619
|
label_offsets=offsets,
|
|
610
620
|
n_workers=n_workers,
|
|
611
621
|
has_labels=has_labels,
|
|
622
|
+
progress=show_progress,
|
|
612
623
|
)
|
|
613
624
|
logger.info(
|
|
614
625
|
"zarr_native_merge: %d touching pairs → building LUT", len(pairs)
|
|
@@ -743,9 +754,9 @@ def zarr_native_merge(
|
|
|
743
754
|
_init_worker(
|
|
744
755
|
lut_path, staged_path, staged_component, out_path, out_component
|
|
745
756
|
)
|
|
746
|
-
it: Any =
|
|
747
|
-
|
|
748
|
-
|
|
757
|
+
it: Any = track(
|
|
758
|
+
tasks, "relabel chunks", n_chunks, enabled=show_progress
|
|
759
|
+
)
|
|
749
760
|
for task in it:
|
|
750
761
|
_relabel_chunk_worker(task)
|
|
751
762
|
else:
|
|
@@ -760,9 +771,12 @@ def zarr_native_merge(
|
|
|
760
771
|
out_component,
|
|
761
772
|
),
|
|
762
773
|
) as pool:
|
|
763
|
-
it =
|
|
764
|
-
|
|
765
|
-
|
|
774
|
+
it = track(
|
|
775
|
+
pool.imap_unordered(_relabel_chunk_worker, tasks),
|
|
776
|
+
"relabel chunks",
|
|
777
|
+
n_chunks,
|
|
778
|
+
enabled=show_progress,
|
|
779
|
+
)
|
|
766
780
|
for _ in it:
|
|
767
781
|
pass
|
|
768
782
|
finally:
|
|
@@ -926,9 +940,10 @@ def merge_tile_labels(
|
|
|
926
940
|
stage_path = os.path.join(_base, "_pws_stage.zarr")
|
|
927
941
|
|
|
928
942
|
import dask
|
|
929
|
-
from dask.diagnostics import ProgressBar
|
|
930
943
|
|
|
931
|
-
|
|
944
|
+
from ._progress import dask_progress
|
|
945
|
+
|
|
946
|
+
ctx = dask_progress("stage tiles", progress)
|
|
932
947
|
logger.info("Staging per-tile labels to %s …", stage_path)
|
|
933
948
|
with ctx:
|
|
934
949
|
dask.compute(
|
|
@@ -36,6 +36,7 @@ import numpy as np
|
|
|
36
36
|
import zarr
|
|
37
37
|
|
|
38
38
|
from ._chunks import cpu_allocation
|
|
39
|
+
from ._progress import track
|
|
39
40
|
|
|
40
41
|
logger = logging.getLogger(__name__)
|
|
41
42
|
|
|
@@ -271,13 +272,19 @@ def build_occupancy_map(
|
|
|
271
272
|
try:
|
|
272
273
|
n_workers = max(1, min(cpu_allocation(), len(regions)))
|
|
273
274
|
if n_workers <= 1:
|
|
274
|
-
for
|
|
275
|
-
_one(starts)
|
|
275
|
+
for _ in track(
|
|
276
|
+
(_one(starts) for starts in regions),
|
|
277
|
+
"occupancy map",
|
|
278
|
+
len(regions),
|
|
279
|
+
):
|
|
280
|
+
pass
|
|
276
281
|
else:
|
|
277
282
|
# Reads and decompression release the GIL, so threads are enough
|
|
278
283
|
# and there is no worker payload to pickle.
|
|
279
284
|
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
280
|
-
for _ in
|
|
285
|
+
for _ in track(
|
|
286
|
+
pool.map(_one, regions), "occupancy map", len(regions)
|
|
287
|
+
):
|
|
281
288
|
pass
|
|
282
289
|
dst.attrs["block"] = list(block)
|
|
283
290
|
dst.attrs["level"] = int(level)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Progress reporting that survives being written to a log file.
|
|
2
|
+
|
|
3
|
+
Every long step in the workflow runs unattended in a batch job, where the
|
|
4
|
+
output is read hours later out of a file. A redrawing bar (dask's
|
|
5
|
+
``ProgressBar``, ``tqdm``) collapses into one enormous unreadable line there,
|
|
6
|
+
so the default here is periodic log records instead -- and a bar only when
|
|
7
|
+
someone is actually watching a terminal.
|
|
8
|
+
|
|
9
|
+
The interval matters more than the precision: the job runs for hours, and the
|
|
10
|
+
question being answered is "is this working or hung?", not "exactly how far".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from contextlib import nullcontext
|
|
19
|
+
from typing import Iterable, Iterator, TypeVar
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
# Often enough to tell progress from a hang, rare enough that a six-hour job
|
|
26
|
+
# leaves a log you can still read.
|
|
27
|
+
PROGRESS_INTERVAL_S = 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_interactive() -> bool:
|
|
31
|
+
"""True when someone is plausibly watching a terminal."""
|
|
32
|
+
return bool(getattr(sys.stderr, "isatty", lambda: False)())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_eta(done: int, total: int, elapsed: float) -> str:
|
|
36
|
+
"""Rough remaining time from a linear extrapolation.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
done, total : int
|
|
41
|
+
Units finished and expected.
|
|
42
|
+
elapsed : float
|
|
43
|
+
Seconds spent so far.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
str
|
|
48
|
+
A short human-readable duration, or ``"?"`` when it cannot be
|
|
49
|
+
estimated yet.
|
|
50
|
+
"""
|
|
51
|
+
if done <= 0 or done >= total:
|
|
52
|
+
return "?"
|
|
53
|
+
left = elapsed / done * (total - done)
|
|
54
|
+
if left < 90:
|
|
55
|
+
return f"{left:.0f}s"
|
|
56
|
+
if left < 5400:
|
|
57
|
+
return f"{left / 60:.0f}m"
|
|
58
|
+
return f"{left / 3600:.1f}h"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def log_progress(label: str, done: int, total: int, started: float) -> None:
|
|
62
|
+
"""Emit one progress line.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
label : str
|
|
67
|
+
What is being worked on, e.g. ``"image.zarr/0"``.
|
|
68
|
+
done, total : int
|
|
69
|
+
Units finished and expected.
|
|
70
|
+
started : float
|
|
71
|
+
``time.monotonic()`` when the work began.
|
|
72
|
+
"""
|
|
73
|
+
elapsed = time.monotonic() - started
|
|
74
|
+
logger.info(
|
|
75
|
+
"%s: %s/%s (%.0f%%) after %.0fm, ~%s left",
|
|
76
|
+
label,
|
|
77
|
+
f"{done:,}",
|
|
78
|
+
f"{total:,}",
|
|
79
|
+
100.0 * done / max(1, total),
|
|
80
|
+
elapsed / 60,
|
|
81
|
+
format_eta(done, total, elapsed),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def track(
|
|
86
|
+
iterable: Iterable[T],
|
|
87
|
+
label: str,
|
|
88
|
+
total: int,
|
|
89
|
+
*,
|
|
90
|
+
enabled: bool = True,
|
|
91
|
+
) -> Iterator[T]:
|
|
92
|
+
"""Yield from *iterable*, reporting progress as it goes.
|
|
93
|
+
|
|
94
|
+
Uses ``tqdm`` when attached to a terminal and periodic log lines
|
|
95
|
+
otherwise, so the same call is right in a notebook and in a SLURM job.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
iterable : iterable
|
|
100
|
+
The work to iterate. Consumed lazily, so this is safe over
|
|
101
|
+
``imap_unordered``.
|
|
102
|
+
label : str
|
|
103
|
+
Description of the work.
|
|
104
|
+
total : int
|
|
105
|
+
Expected number of items, used for the percentage and the ETA.
|
|
106
|
+
enabled : bool, optional
|
|
107
|
+
Set ``False`` to pass items straight through. Default ``True``.
|
|
108
|
+
|
|
109
|
+
Yields
|
|
110
|
+
------
|
|
111
|
+
object
|
|
112
|
+
The items of *iterable*, unchanged.
|
|
113
|
+
"""
|
|
114
|
+
if not enabled:
|
|
115
|
+
yield from iterable
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if is_interactive():
|
|
119
|
+
try:
|
|
120
|
+
from tqdm.auto import tqdm
|
|
121
|
+
|
|
122
|
+
yield from tqdm(iterable, total=total, desc=label)
|
|
123
|
+
return
|
|
124
|
+
except ImportError:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
started = time.monotonic()
|
|
128
|
+
last = started
|
|
129
|
+
done = 0
|
|
130
|
+
logger.info("%s: starting (%s items)", label, f"{total:,}")
|
|
131
|
+
for item in iterable:
|
|
132
|
+
yield item
|
|
133
|
+
done += 1
|
|
134
|
+
now = time.monotonic()
|
|
135
|
+
if now - last >= PROGRESS_INTERVAL_S:
|
|
136
|
+
last = now
|
|
137
|
+
log_progress(label, done, total, started)
|
|
138
|
+
log_progress(label, done, total, started)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def dask_progress(label: str, enabled: bool = True):
|
|
142
|
+
"""Progress context manager for a dask computation.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
contextmanager
|
|
147
|
+
``ProgressBar`` on a terminal, a periodically-logging callback
|
|
148
|
+
otherwise, or a no-op when *enabled* is false.
|
|
149
|
+
"""
|
|
150
|
+
if not enabled:
|
|
151
|
+
return nullcontext()
|
|
152
|
+
|
|
153
|
+
logger.info("writing %s …", label)
|
|
154
|
+
if is_interactive():
|
|
155
|
+
from dask.diagnostics import ProgressBar
|
|
156
|
+
|
|
157
|
+
return ProgressBar()
|
|
158
|
+
|
|
159
|
+
from dask.callbacks import Callback
|
|
160
|
+
|
|
161
|
+
class _LogProgress(Callback):
|
|
162
|
+
"""Count finished dask tasks and log every PROGRESS_INTERVAL_S."""
|
|
163
|
+
|
|
164
|
+
def _start_state(self, dsk, state):
|
|
165
|
+
self._total = sum(
|
|
166
|
+
len(state[k])
|
|
167
|
+
for k in ("ready", "waiting", "running", "finished")
|
|
168
|
+
)
|
|
169
|
+
self._done = 0
|
|
170
|
+
self._t0 = time.monotonic()
|
|
171
|
+
self._last = self._t0
|
|
172
|
+
|
|
173
|
+
def _posttask(self, key, result, dsk, state, worker_id):
|
|
174
|
+
self._done += 1
|
|
175
|
+
now = time.monotonic()
|
|
176
|
+
if now - self._last >= PROGRESS_INTERVAL_S:
|
|
177
|
+
self._last = now
|
|
178
|
+
log_progress(label, self._done, self._total, self._t0)
|
|
179
|
+
|
|
180
|
+
def _finish(self, dsk, state, errored):
|
|
181
|
+
if not errored:
|
|
182
|
+
log_progress(label, self._total, self._total, self._t0)
|
|
183
|
+
|
|
184
|
+
return _LogProgress()
|
|
@@ -48,6 +48,7 @@ from __future__ import annotations
|
|
|
48
48
|
import glob
|
|
49
49
|
import logging
|
|
50
50
|
import math
|
|
51
|
+
import time as _time
|
|
51
52
|
from concurrent.futures import ThreadPoolExecutor
|
|
52
53
|
from contextlib import nullcontext as _nullcontext
|
|
53
54
|
from itertools import product as _iproduct
|
|
@@ -59,6 +60,10 @@ import numpy as np
|
|
|
59
60
|
import zarr
|
|
60
61
|
|
|
61
62
|
from .._chunks import cpu_allocation
|
|
63
|
+
from .._progress import (
|
|
64
|
+
PROGRESS_INTERVAL_S as _PROGRESS_INTERVAL_S,
|
|
65
|
+
)
|
|
66
|
+
from .._progress import dask_progress, log_progress
|
|
62
67
|
from .._io import load_ome_zarr, zarr_compressor_kwargs
|
|
63
68
|
|
|
64
69
|
logger = logging.getLogger(__name__)
|
|
@@ -423,6 +428,8 @@ def _stream_strided_level(
|
|
|
423
428
|
dst: "zarr.Array",
|
|
424
429
|
strides: tuple[int, ...],
|
|
425
430
|
n_workers: int = 4,
|
|
431
|
+
label: str = "level",
|
|
432
|
+
progress: bool = True,
|
|
426
433
|
) -> None:
|
|
427
434
|
"""Write *dst* as the strided subsample of *src*, one chunk at a time.
|
|
428
435
|
|
|
@@ -457,13 +464,27 @@ def _stream_strided_level(
|
|
|
457
464
|
dst[out_sl] = np.asarray(src[src_sl])[take]
|
|
458
465
|
|
|
459
466
|
indices = list(_iproduct(*[range(g) for g in grid]))
|
|
467
|
+
total = len(indices)
|
|
468
|
+
started = _time.monotonic()
|
|
469
|
+
last = started
|
|
470
|
+
|
|
471
|
+
def _tick(done: int) -> None:
|
|
472
|
+
nonlocal last
|
|
473
|
+
now = _time.monotonic()
|
|
474
|
+
if progress and now - last >= _PROGRESS_INTERVAL_S:
|
|
475
|
+
last = now
|
|
476
|
+
log_progress(label, done, total, started)
|
|
477
|
+
|
|
460
478
|
if n_workers <= 1:
|
|
461
|
-
for idx in indices:
|
|
479
|
+
for done, idx in enumerate(indices, 1):
|
|
462
480
|
_one(idx)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
481
|
+
_tick(done)
|
|
482
|
+
else:
|
|
483
|
+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
|
|
484
|
+
for done, _ in enumerate(pool.map(_one, indices), 1):
|
|
485
|
+
_tick(done)
|
|
486
|
+
if progress:
|
|
487
|
+
log_progress(label, total, total, started)
|
|
467
488
|
|
|
468
489
|
|
|
469
490
|
# One chunk is one file without sharding. A shared cluster filesystem starts
|
|
@@ -531,29 +552,8 @@ def _bounded_scheduler(arr: da.Array):
|
|
|
531
552
|
|
|
532
553
|
|
|
533
554
|
def _progress_ctx(progress: bool, label: str):
|
|
534
|
-
"""
|
|
535
|
-
|
|
536
|
-
Parameters
|
|
537
|
-
----------
|
|
538
|
-
progress : bool
|
|
539
|
-
Whether to show a dask progress bar.
|
|
540
|
-
label : str
|
|
541
|
-
Name logged just before the bar.
|
|
542
|
-
|
|
543
|
-
Returns
|
|
544
|
-
-------
|
|
545
|
-
contextmanager
|
|
546
|
-
A ``ProgressBar`` when *progress* is set, else a no-op
|
|
547
|
-
context manager.
|
|
548
|
-
"""
|
|
549
|
-
if not progress:
|
|
550
|
-
from contextlib import nullcontext
|
|
551
|
-
|
|
552
|
-
return nullcontext()
|
|
553
|
-
from dask.diagnostics import ProgressBar
|
|
554
|
-
|
|
555
|
-
logger.info("writing %s …", label)
|
|
556
|
-
return ProgressBar()
|
|
555
|
+
"""Progress context for a long dask write; see patchworks._progress."""
|
|
556
|
+
return dask_progress(label, progress)
|
|
557
557
|
|
|
558
558
|
|
|
559
559
|
def _to_zarr_level(
|
|
@@ -786,7 +786,12 @@ def _write_pyramid(
|
|
|
786
786
|
),
|
|
787
787
|
)
|
|
788
788
|
_stream_strided_level(
|
|
789
|
-
src_arr,
|
|
789
|
+
src_arr,
|
|
790
|
+
dst_arr,
|
|
791
|
+
strides,
|
|
792
|
+
n_workers=cpu_allocation(),
|
|
793
|
+
label=f"{Path(group_path).name}/{i}",
|
|
794
|
+
progress=progress,
|
|
790
795
|
)
|
|
791
796
|
scale = [base_scale[k] * (strides[k] ** i) for k in range(len(axes))]
|
|
792
797
|
datasets.append(_dataset(str(i), scale))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tests for log-friendly progress reporting."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from patchworks import _progress
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_track_yields_everything_unchanged():
|
|
9
|
+
"""Progress reporting must never alter or drop the work it wraps."""
|
|
10
|
+
items = list(range(50))
|
|
11
|
+
assert list(_progress.track(iter(items), "x", len(items))) == items
|
|
12
|
+
# ...including when disabled, which is the passthrough path.
|
|
13
|
+
assert (
|
|
14
|
+
list(_progress.track(iter(items), "x", len(items), enabled=False))
|
|
15
|
+
== items
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_track_logs_on_a_non_tty(monkeypatch, caplog):
|
|
20
|
+
"""A batch job gets log records, not a redrawing bar.
|
|
21
|
+
|
|
22
|
+
The whole point: a carriage-returning bar collapses a SLURM log into one
|
|
23
|
+
unreadable line, which is why convert ran silent rather than use one.
|
|
24
|
+
"""
|
|
25
|
+
monkeypatch.setattr(_progress, "is_interactive", lambda: False)
|
|
26
|
+
# Force every item to report, instead of waiting out the real interval.
|
|
27
|
+
monkeypatch.setattr(_progress, "PROGRESS_INTERVAL_S", -1.0)
|
|
28
|
+
with caplog.at_level(logging.INFO, logger=_progress.logger.name):
|
|
29
|
+
list(_progress.track(iter(range(3)), "merge chunks", 3))
|
|
30
|
+
|
|
31
|
+
messages = [r.getMessage() for r in caplog.records]
|
|
32
|
+
assert any("starting" in m for m in messages)
|
|
33
|
+
assert any("merge chunks" in m and "%" in m for m in messages)
|
|
34
|
+
# No carriage returns: that is what makes it readable in a file.
|
|
35
|
+
assert not any("\r" in m for m in messages)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_track_reports_the_final_count_even_when_quiet():
|
|
39
|
+
"""A run shorter than one interval must still say it finished."""
|
|
40
|
+
records = []
|
|
41
|
+
handler = logging.Handler()
|
|
42
|
+
handler.emit = records.append
|
|
43
|
+
_progress.logger.addHandler(handler)
|
|
44
|
+
_progress.logger.setLevel(logging.INFO)
|
|
45
|
+
try:
|
|
46
|
+
list(_progress.track(iter(range(2)), "quick", 2))
|
|
47
|
+
finally:
|
|
48
|
+
_progress.logger.removeHandler(handler)
|
|
49
|
+
assert any("2/2" in r.getMessage() for r in records)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_eta_is_sane_and_degrades_gracefully():
|
|
53
|
+
"""An ETA is a linear extrapolation, and refuses to invent one."""
|
|
54
|
+
assert _progress.format_eta(0, 100, 10.0) == "?" # nothing measured yet
|
|
55
|
+
assert _progress.format_eta(100, 100, 10.0) == "?" # already done
|
|
56
|
+
# Half done in 60 s → roughly another 60 s.
|
|
57
|
+
assert _progress.format_eta(50, 100, 60.0) == "60s"
|
|
58
|
+
# Units scale so a six-hour job does not report "21600s".
|
|
59
|
+
assert _progress.format_eta(1, 100, 60.0).endswith(("m", "h"))
|
|
60
|
+
assert _progress.format_eta(1, 1000, 600.0).endswith("h")
|
|
@@ -121,3 +121,40 @@ def test_shipped_multi_configs_are_consistent():
|
|
|
121
121
|
for path in paths:
|
|
122
122
|
own = yaml.safe_load(path.read_text())
|
|
123
123
|
assert not set(own) & set(_CONVERT_KEYS), path.name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _workflow_dir() -> Path:
|
|
127
|
+
return Path(__file__).resolve().parents[1] / "workflow"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_occupancy_is_a_submitted_rule_not_a_localrule():
|
|
131
|
+
"""The occupancy build must never run on the submit host.
|
|
132
|
+
|
|
133
|
+
It streams the entire image. Doing that in the run_multi driver ran it on
|
|
134
|
+
a login node, where a multi-terabyte read is killed with no traceback --
|
|
135
|
+
the run just returned to the prompt. Only fetch_model may be local (it
|
|
136
|
+
needs network); everything else has to get a real allocation.
|
|
137
|
+
"""
|
|
138
|
+
wf = _workflow_dir()
|
|
139
|
+
snakefile = (wf / "Snakefile").read_text()
|
|
140
|
+
local_block = snakefile.split("localrules:")[1].split("rule ")[0]
|
|
141
|
+
local = {
|
|
142
|
+
line.strip().rstrip(",")
|
|
143
|
+
for line in local_block.splitlines()
|
|
144
|
+
if line.strip() and not line.strip().startswith("#")
|
|
145
|
+
}
|
|
146
|
+
assert local == {"fetch_model"}, local
|
|
147
|
+
|
|
148
|
+
rules = (wf / "rules" / "convert.smk").read_text()
|
|
149
|
+
assert "rule occupancy:" in rules
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_occupancy_is_not_rebuilt_by_the_driver():
|
|
153
|
+
"""run_multi must ask Snakemake for the map, not build it in-process.
|
|
154
|
+
|
|
155
|
+
An in-process build bypasses the scheduler entirely, which is how it ended
|
|
156
|
+
up on the login node.
|
|
157
|
+
"""
|
|
158
|
+
src = (_workflow_dir() / "scripts" / "run_multi.py").read_text()
|
|
159
|
+
assert "build_occupancy_map(" not in src
|
|
160
|
+
assert "occupancy.zarr" in src
|
|
@@ -56,10 +56,17 @@ set-resources:
|
|
|
56
56
|
mem_mb: "attempt * 64000"
|
|
57
57
|
cpus_per_task: 32
|
|
58
58
|
runtime: 360
|
|
59
|
+
occupancy:
|
|
60
|
+
# One streaming pass over the whole image, max-pooling each brick. Reads a
|
|
61
|
+
# lot but holds little: memory is bounded by the worker count times one
|
|
62
|
+
# region, not by the image. Runtime tracks the read, so give it room --
|
|
63
|
+
# this is a multi-terabyte scan on a real dataset.
|
|
64
|
+
mem_mb: "attempt * 32000"
|
|
65
|
+
cpus_per_task: 16
|
|
66
|
+
runtime: 360
|
|
59
67
|
prepare:
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# config against that store).
|
|
68
|
+
# Cheap now: it reduces the already-built occupancy map over each tile's
|
|
69
|
+
# footprint instead of touching the image.
|
|
63
70
|
mem_mb: "attempt * 32000"
|
|
64
71
|
runtime: 120
|
|
65
72
|
segment:
|
|
@@ -8,6 +8,13 @@ IMAGE = f"{WORK}/image.zarr"
|
|
|
8
8
|
# avoids wiping the whole store on a re-run (same trick as imcf/sopa).
|
|
9
9
|
IMAGE_OK = f"{IMAGE}/zarr.json"
|
|
10
10
|
|
|
11
|
+
# Max-pooled occupancy summary, a sibling of the image (not a node inside it,
|
|
12
|
+
# which zarr would refuse to walk). Shared by every config against this image,
|
|
13
|
+
# so it is keyed on the image and the level rather than on label_name.
|
|
14
|
+
OCCUPANCY = f"{WORK}/image.occupancy.zarr/{int(config.get('level', 0))}"
|
|
15
|
+
OCCUPANCY_OK = f"{OCCUPANCY}/zarr.json"
|
|
16
|
+
OCCUPANCYLOG = f"{WORK}/logs/occupancy.log"
|
|
17
|
+
|
|
11
18
|
# Everything below is per-segmentation, namespaced under WORK/<label_name>/, so
|
|
12
19
|
# running the workflow twice with two configs (different label_name, e.g.
|
|
13
20
|
# "nuclei_labels" and "cell_labels") against the *same* work_dir never
|
|
@@ -24,10 +31,14 @@ STAGE = f"{RUN}/stage.zarr"
|
|
|
24
31
|
STAGE_OK = f"{STAGE}.done"
|
|
25
32
|
|
|
26
33
|
|
|
27
|
-
# Logs: one
|
|
28
|
-
#
|
|
34
|
+
# Logs: one file per step. They used to share a single steps.log, but
|
|
35
|
+
# Snakemake clears a rule's declared log before the job runs, so each step
|
|
36
|
+
# wiped the previous one's output -- by the time a run finished, only the last
|
|
37
|
+
# step's log survived and a failure earlier on left nothing to read.
|
|
29
38
|
LOGS = f"{RUN}/logs"
|
|
30
|
-
|
|
39
|
+
CONVERTLOG = f"{LOGS}/convert.log"
|
|
40
|
+
PREPARELOG = f"{LOGS}/prepare.log"
|
|
41
|
+
MERGELOG = f"{LOGS}/merge.log"
|
|
31
42
|
|
|
32
43
|
# Marker that the segmentation model is cached locally. Produced by a local
|
|
33
44
|
# rule (runs on the networked submit host) so offline GPU nodes never download.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Convert the input to a pyramidal OME-ZARR.
|
|
2
|
+
|
|
3
|
+
rule convert:
|
|
4
|
+
output:
|
|
5
|
+
# marker file inside the store; existence => skip re-conversion.
|
|
6
|
+
IMAGE_OK,
|
|
7
|
+
log:
|
|
8
|
+
CONVERTLOG,
|
|
9
|
+
script:
|
|
10
|
+
"../scripts/convert.py"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Build the occupancy map as a real job. It streams the whole image, so doing
|
|
14
|
+
# it in the run_multi driver ran it on the login node, where a multi-terabyte
|
|
15
|
+
# read is killed with no traceback. Built once and reused by every config.
|
|
16
|
+
rule occupancy:
|
|
17
|
+
input:
|
|
18
|
+
IMAGE_OK,
|
|
19
|
+
output:
|
|
20
|
+
OCCUPANCY_OK,
|
|
21
|
+
log:
|
|
22
|
+
OCCUPANCYLOG,
|
|
23
|
+
script:
|
|
24
|
+
"../scripts/build_occupancy.py"
|
|
@@ -18,11 +18,15 @@ rule fetch_model:
|
|
|
18
18
|
checkpoint prepare:
|
|
19
19
|
input:
|
|
20
20
|
IMAGE_OK,
|
|
21
|
+
# Depend on the map rather than building it inline: it streams the
|
|
22
|
+
# whole image, and several configs' prepare steps run concurrently, so
|
|
23
|
+
# inline each would stream the volume and all but one discard it.
|
|
24
|
+
OCCUPANCY_OK,
|
|
21
25
|
output:
|
|
22
26
|
tiles=TILES,
|
|
23
27
|
stage=touch(STAGE_OK),
|
|
24
28
|
log:
|
|
25
|
-
|
|
29
|
+
PREPARELOG,
|
|
26
30
|
script:
|
|
27
31
|
"../scripts/prepare_tiles.py"
|
|
28
32
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Snakemake script: build the max-pooled occupancy map for one level.
|
|
2
|
+
|
|
3
|
+
Its own rule, rather than work done inside ``run_multi``, because it streams
|
|
4
|
+
the entire image: doing that in the driver process runs it on the **login
|
|
5
|
+
node**, where a multi-terabyte read is killed without a traceback. As a rule
|
|
6
|
+
it is submitted like any other job and gets a real allocation.
|
|
7
|
+
|
|
8
|
+
Built once per image and shared by every config that segments it -- otherwise
|
|
9
|
+
the concurrently-running ``prepare`` steps would each stream the whole volume
|
|
10
|
+
and all but one would throw the result away.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from patchworks import block_for_tile, build_occupancy_map
|
|
14
|
+
|
|
15
|
+
from _pw import start_log
|
|
16
|
+
|
|
17
|
+
start_log(snakemake.log[0]) # noqa: F821
|
|
18
|
+
cfg = snakemake.config # noqa: F821
|
|
19
|
+
|
|
20
|
+
image_store = str(snakemake.input[0]).removesuffix("/zarr.json") # noqa: F821
|
|
21
|
+
level = int(cfg.get("level", 0))
|
|
22
|
+
|
|
23
|
+
# Sizing the block from the tile keeps the map discriminating: a block as
|
|
24
|
+
# coarse as the tile itself would make every tile test occupied.
|
|
25
|
+
tile_shape = cfg.get("tile_shape")
|
|
26
|
+
kwargs = (
|
|
27
|
+
{"block": block_for_tile(tuple(tile_shape))}
|
|
28
|
+
if isinstance(tile_shape, (list, tuple))
|
|
29
|
+
else {}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
path = build_occupancy_map(image_store, level=level, **kwargs)
|
|
33
|
+
print(f"[patchworks] occupancy map ready at {path}", flush=True)
|
|
@@ -30,6 +30,9 @@ to_ome_zarr(
|
|
|
30
30
|
chunks=tuple(chunks) if chunks else None,
|
|
31
31
|
shard=bool(cfg.get("shard", False)),
|
|
32
32
|
reuse_pyramid=bool(cfg.get("reuse_pyramid", False)),
|
|
33
|
-
|
|
33
|
+
# Progress goes to the log as periodic lines, not a redrawing bar:
|
|
34
|
+
# this runs for hours in a batch job where silence is indistinguishable
|
|
35
|
+
# from a hang.
|
|
36
|
+
progress=True,
|
|
34
37
|
overwrite=True,
|
|
35
38
|
)
|
|
@@ -91,7 +91,8 @@ _, n_objects = merge_tile_labels(
|
|
|
91
91
|
output_chunks=out_chunks,
|
|
92
92
|
sequential_labels=cfg.get("sequential_labels", True),
|
|
93
93
|
n_workers=cfg.get("merge_workers") or default_workers,
|
|
94
|
-
|
|
94
|
+
# Periodic log lines rather than a bar (see convert.py).
|
|
95
|
+
progress=True,
|
|
95
96
|
return_count=True,
|
|
96
97
|
label_counts=label_counts,
|
|
97
98
|
)
|
|
@@ -100,7 +101,7 @@ group = register_labels(
|
|
|
100
101
|
label_name,
|
|
101
102
|
n_levels=int(cfg.get("pyramid_levels", 5)),
|
|
102
103
|
downscale=int(cfg.get("pyramid_downscale", 2)),
|
|
103
|
-
progress=
|
|
104
|
+
progress=True,
|
|
104
105
|
n_objects=n_objects,
|
|
105
106
|
)
|
|
106
107
|
|
|
@@ -272,6 +272,10 @@ def main() -> None:
|
|
|
272
272
|
seg_cfgs = [{**common_cfg, **_load_yaml(p)} for p in seg_config_paths]
|
|
273
273
|
work_dir = _validate_configs(seg_config_paths, seg_cfgs)
|
|
274
274
|
image_store = f"{work_dir}/image.zarr"
|
|
275
|
+
# Shared by every config, hence keyed on the image and level, not on a
|
|
276
|
+
# label_name. Levels are validated identical across configs below.
|
|
277
|
+
_level = int(seg_cfgs[0].get("level", 0))
|
|
278
|
+
occupancy_store = f"{work_dir}/image.occupancy.zarr/{_level}"
|
|
275
279
|
|
|
276
280
|
# Each phase gets its own Snakemake state directory (the lock lives in the
|
|
277
281
|
# working directory, not the config), so unlocking has to cover all of
|
|
@@ -311,7 +315,15 @@ def main() -> None:
|
|
|
311
315
|
cores=args.cores,
|
|
312
316
|
dry_run=args.dry_run,
|
|
313
317
|
state_dir=Path(work_dir) / ".snakemake_convert",
|
|
314
|
-
|
|
318
|
+
# Both in one phase-A call so they run as SLURM jobs. The
|
|
319
|
+
# occupancy map streams the whole image; building it here in the
|
|
320
|
+
# driver ran it on the login node, where the read is killed
|
|
321
|
+
# without a traceback. It is shared by every config, so it must
|
|
322
|
+
# not be left to the concurrent `prepare` steps either.
|
|
323
|
+
targets=[
|
|
324
|
+
f"{image_store}/zarr.json",
|
|
325
|
+
f"{occupancy_store}/zarr.json",
|
|
326
|
+
],
|
|
315
327
|
jobname_prefix=slurm_jobname_prefix("convert"),
|
|
316
328
|
common=common_path,
|
|
317
329
|
),
|
|
@@ -327,31 +339,6 @@ def main() -> None:
|
|
|
327
339
|
)
|
|
328
340
|
sys.exit(rc)
|
|
329
341
|
|
|
330
|
-
# Still phase A: build the occupancy map here too. Every config's `prepare`
|
|
331
|
-
# needs it, and they are about to run concurrently -- so leaving it to them
|
|
332
|
-
# means all of them stream the whole image, and all but one throw the
|
|
333
|
-
# result away. It covers every channel, so one build serves them all.
|
|
334
|
-
if not args.dry_run:
|
|
335
|
-
from patchworks import block_for_tile, build_occupancy_map
|
|
336
|
-
|
|
337
|
-
# tile_shape is validated identical across configs, so one block suits
|
|
338
|
-
# them all. Sizing it from the tile keeps the map discriminating: a
|
|
339
|
-
# block as coarse as the tile makes every tile test occupied.
|
|
340
|
-
tile_shape = seg_cfgs[0].get("tile_shape")
|
|
341
|
-
block = (
|
|
342
|
-
block_for_tile(tile_shape)
|
|
343
|
-
if isinstance(tile_shape, (list, tuple))
|
|
344
|
-
else None
|
|
345
|
-
)
|
|
346
|
-
levels = {int(cfg.get("level", 0)) for cfg in seg_cfgs}
|
|
347
|
-
for level in sorted(levels):
|
|
348
|
-
print(
|
|
349
|
-
f"[run_multi] building occupancy map for level {level} …",
|
|
350
|
-
flush=True,
|
|
351
|
-
)
|
|
352
|
-
kwargs = {"block": block} if block else {}
|
|
353
|
-
build_occupancy_map(image_store, level=level, **kwargs)
|
|
354
|
-
|
|
355
342
|
# Phase B: the segmentations touch disjoint files under
|
|
356
343
|
# work_dir/<label_name>/, so run them together and let the GPU partition
|
|
357
344
|
# stay busy instead of idling through each config's prepare and merge.
|
|
@@ -8,6 +8,7 @@ batch writes disjoint chunks of the stage store, so batches never collide.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import time
|
|
11
12
|
|
|
12
13
|
from patchworks import stage_tile
|
|
13
14
|
|
|
@@ -33,7 +34,9 @@ component = manifest.get("target_component", "staged")
|
|
|
33
34
|
tile_shape = tuple(manifest["tile_shape"])
|
|
34
35
|
|
|
35
36
|
counts = {}
|
|
36
|
-
|
|
37
|
+
batch_started = time.monotonic()
|
|
38
|
+
for n, index in enumerate(indices, 1):
|
|
39
|
+
started = time.monotonic()
|
|
37
40
|
counts[index] = stage_tile(
|
|
38
41
|
image,
|
|
39
42
|
fn,
|
|
@@ -44,7 +47,15 @@ for index in indices:
|
|
|
44
47
|
overlap=manifest["overlap"],
|
|
45
48
|
component=component,
|
|
46
49
|
)
|
|
47
|
-
|
|
50
|
+
# The per-tile time is what `tiles_per_job` has to be sized from: a job's
|
|
51
|
+
# wall time is roughly N x this, and it must stay inside the QOS ceiling.
|
|
52
|
+
# The first tile in a batch also carries the model load, so it runs long.
|
|
53
|
+
took = time.monotonic() - started
|
|
54
|
+
print(
|
|
55
|
+
f"[patchworks] tile {index} ({n}/{len(indices)}): "
|
|
56
|
+
f"{counts[index]} label(s) in {took:.0f}s",
|
|
57
|
+
flush=True,
|
|
58
|
+
)
|
|
48
59
|
|
|
49
60
|
# The marker carries each tile's label count. That is what lets merge derive
|
|
50
61
|
# every tile's global id range with a cumulative sum instead of streaming the
|
|
@@ -52,4 +63,8 @@ for index in indices:
|
|
|
52
63
|
# down instead of throwing them away.
|
|
53
64
|
with open(snakemake.output[0], "w") as fh: # noqa: F821
|
|
54
65
|
json.dump({"batch": batch, "counts": counts}, fh)
|
|
55
|
-
print(
|
|
66
|
+
print(
|
|
67
|
+
f"[patchworks] batch {batch}: {len(indices)} tile(s) done in "
|
|
68
|
+
f"{(time.monotonic() - batch_started) / 60:.1f}m",
|
|
69
|
+
flush=True,
|
|
70
|
+
)
|
|
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
|