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.
Files changed (100) hide show
  1. {patchworks-2.3.0 → patchworks-2.4.0}/PKG-INFO +1 -1
  2. {patchworks-2.3.0 → patchworks-2.4.0}/cliff.toml +3 -0
  3. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/dog.md +16 -11
  4. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/performance.md +3 -0
  5. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/snakemake.md +2 -1
  6. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_merge.py +31 -16
  7. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_occupancy.py +10 -3
  8. patchworks-2.4.0/src/patchworks/_progress.py +184 -0
  9. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/ome_zarr.py +34 -29
  10. patchworks-2.4.0/tests/test_progress.py +60 -0
  11. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_run_multi.py +37 -0
  12. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/profile/slurm/config.yaml +10 -3
  13. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/common.smk +14 -3
  14. patchworks-2.4.0/workflow/rules/convert.smk +24 -0
  15. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/merge.smk +1 -1
  16. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/rules/segment.smk +5 -1
  17. patchworks-2.4.0/workflow/scripts/build_occupancy.py +33 -0
  18. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/convert.py +4 -1
  19. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/merge.py +3 -2
  20. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/run_multi.py +13 -26
  21. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/segment_tile.py +18 -3
  22. patchworks-2.3.0/workflow/rules/convert.smk +0 -10
  23. {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/docs.yml +0 -0
  24. {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/lint.yml +0 -0
  25. {patchworks-2.3.0 → patchworks-2.4.0}/.github/workflows/release.yml +0 -0
  26. {patchworks-2.3.0 → patchworks-2.4.0}/.gitignore +0 -0
  27. {patchworks-2.3.0 → patchworks-2.4.0}/.markdownlint-cli2.yaml +0 -0
  28. {patchworks-2.3.0 → patchworks-2.4.0}/LICENSE +0 -0
  29. {patchworks-2.3.0 → patchworks-2.4.0}/README.md +0 -0
  30. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/chunks.md +0 -0
  31. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/cluster.md +0 -0
  32. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/io.md +0 -0
  33. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/merge_tile_labels.md +0 -0
  34. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/cellpose.md +0 -0
  35. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/dog.md +0 -0
  36. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/napari.md +0 -0
  37. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/plugins/ome_zarr.md +0 -0
  38. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/postprocess.md +0 -0
  39. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/relabel.md +0 -0
  40. {patchworks-2.3.0 → patchworks-2.4.0}/docs/api/tile_process.md +0 -0
  41. {patchworks-2.3.0 → patchworks-2.4.0}/docs/assets/logo.png +0 -0
  42. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_2d.md +0 -0
  43. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_2d.py +0 -0
  44. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_3d.md +0 -0
  45. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/cellpose_3d.py +0 -0
  46. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/custom.md +0 -0
  47. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/custom_method.py +0 -0
  48. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/dog.py +0 -0
  49. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/standalone_merge.md +0 -0
  50. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/stardist.md +0 -0
  51. {patchworks-2.3.0 → patchworks-2.4.0}/docs/examples/stardist_2d.py +0 -0
  52. {patchworks-2.3.0 → patchworks-2.4.0}/docs/getting_started.md +0 -0
  53. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/custom_segmentation.md +0 -0
  54. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/gpu_distributed.md +0 -0
  55. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/label_relations.md +0 -0
  56. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/measurements.md +0 -0
  57. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/merging.md +0 -0
  58. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/ome_zarr_napari.md +0 -0
  59. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/pitfalls.md +0 -0
  60. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/skip_empty.md +0 -0
  61. {patchworks-2.3.0 → patchworks-2.4.0}/docs/guide/tiling.md +0 -0
  62. {patchworks-2.3.0 → patchworks-2.4.0}/docs/index.md +0 -0
  63. {patchworks-2.3.0 → patchworks-2.4.0}/mkdocs.yml +0 -0
  64. {patchworks-2.3.0 → patchworks-2.4.0}/pyproject.toml +0 -0
  65. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/__init__.py +0 -0
  66. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_chunks.py +0 -0
  67. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_cluster.py +0 -0
  68. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_core.py +0 -0
  69. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_distributed.py +0 -0
  70. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_gpu.py +0 -0
  71. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_io.py +0 -0
  72. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_postprocess.py +0 -0
  73. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_relabel.py +0 -0
  74. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/_relations.py +0 -0
  75. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/__init__.py +0 -0
  76. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/cellpose.py +0 -0
  77. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/dog.py +0 -0
  78. {patchworks-2.3.0 → patchworks-2.4.0}/src/patchworks/plugins/napari.py +0 -0
  79. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_allocation.py +0 -0
  80. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_core.py +0 -0
  81. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_distributed.py +0 -0
  82. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_dog.py +0 -0
  83. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_gpu.py +0 -0
  84. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_napari.py +0 -0
  85. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_occupancy.py +0 -0
  86. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_ome_zarr.py +0 -0
  87. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_postprocess.py +0 -0
  88. {patchworks-2.3.0 → patchworks-2.4.0}/tests/test_relations.py +0 -0
  89. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/README.md +0 -0
  90. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/Snakefile +0 -0
  91. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/common.yaml +0 -0
  92. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config.yaml +0 -0
  93. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_cilia.yaml +0 -0
  94. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_cyto.yaml +0 -0
  95. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/config_nuclei.yaml +0 -0
  96. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/config/multi.yaml +0 -0
  97. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/pixi.toml +0 -0
  98. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/_pw.py +0 -0
  99. {patchworks-2.3.0 → patchworks-2.4.0}/workflow/scripts/fetch_model.py +0 -0
  100. {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.0
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
- tile_shape: [16, 1024, 1024]
153
- overlap: 30 # cover the PSF support (decon) + the DoG's high_sigma
154
- skip_empty: true
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
- calls `estimate_empty_tiles()` before submitting any `segment` jobs,
210
- regardless of `method`, so cilia/DoG runs skip background tiles exactly
211
- like Cellpose runs no extra config needed beyond `skip_empty: true`
212
- (the default).
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/<index>.log` (per tile) or `logs/steps.log` — the real traceback is there |
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 = [_one(t) for t in tasks]
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(pool.map(_one, tasks))
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 = tasks
747
- if show_progress and _tqdm is not None:
748
- it = _tqdm(it, total=n_chunks, desc="relabel chunks")
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 = pool.imap_unordered(_relabel_chunk_worker, tasks)
764
- if show_progress and _tqdm is not None:
765
- it = _tqdm(it, total=n_chunks, desc="relabel chunks")
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
- ctx = ProgressBar() if progress else _nullcontext()
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 starts in regions:
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 pool.map(_one, regions):
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
- return
464
- with ThreadPoolExecutor(max_workers=n_workers) as pool:
465
- for _ in pool.map(_one, indices):
466
- pass
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
- """Return a progress-bar context manager.
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, dst_arr, strides, n_workers=cpu_allocation()
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
- # Also builds the max-pooled occupancy map on first use for an image.zarr
61
- # that predates it (one bounded streaming pass, then shared by every
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 shared file for the sequential CPU steps (convert/prepare/merge),
28
- # one file per tile for the GPU segment jobs.
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
- STEPLOG = f"{LOGS}/steps.log"
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"
@@ -7,6 +7,6 @@ rule merge:
7
7
  output:
8
8
  touch(f"{RUN}/labels.done"),
9
9
  log:
10
- STEPLOG,
10
+ MERGELOG,
11
11
  script:
12
12
  "../scripts/merge.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
- STEPLOG,
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
- progress=False,
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
- progress=False,
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=False,
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
- targets=[f"{image_store}/zarr.json"],
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
- for index in indices:
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
- print(f"[patchworks] segmented tile {index}: {counts[index]} label(s)")
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(f"[patchworks] batch {batch}: {len(indices)} tile(s) done")
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
+ )
@@ -1,10 +0,0 @@
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
- STEPLOG,
9
- script:
10
- "../scripts/convert.py"
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