Skip to content

Commit 12fdb93

Browse files
ieivanovclaude
andauthored
feat(nextflow): integrate virtual-stain into mantis-v2 pipeline (#271)
* feat(nextflow): integrate virtual-stain into mantis-v2 pipeline Recreate the Nextflow virtual-stain integration against the current in-process cytoland CLI (post-#267), replacing the #259 approach which was built on the old `viscy predict` + `--copy` temp-zarr flow. - New `nextflow/modules/virtual_stain.nf`: init → preprocess → fan-out per-position GPU prediction. Per-position work is a single `biahub virtual-stain --cluster debug` call (no temp zarr, no --copy). - `viscy preprocess` runs over the whole input plate to compute the normalization statistics the model reads via read_norm_meta. - Both `biahub virtual-stain` and `viscy` run under biahub's `stain` extra (cytoland → viscy-utils provides the `viscy` console script). - Add a `gpu` process label (queue=gpu) to nextflow.config. - Wire virtual_stain_wf after reconstruct in mantis-v2.nf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix biahub virtual-stain mem request * refactor: unify per-position resource requests across step CLIs Every step CLI (deskew, flat-field, apply-inv-tf, virtual-stain) now emits its per-position resource request through a single shared helper, biahub.cli.utils.echo_resources, as a JSON payload: RESOURCES:{"cpus": 16, "mem_gb": 64, "time_min": 480} The same (cpus, total mem_gb, time_min) values feed both the CLI's own slurm_* submission args and, via parse_resources in nextflow, the Nextflow per-position task directives — so the SLURM fan-out and the Nextflow fan-out can no longer request different resources. - Add echo_resources() and parse the JSON in common.nf::parse_resources, which now returns time_min alongside cpus/mem_gb. - Emit memory as a TOTAL (mem_gb) and request it via slurm_mem rather than slurm_mem_per_cpu, fixing virtual-stain's per-cpu over-request. - Wire each CLI's slurm_time from time_min; drop the hardcoded `time` literals in the run_* processes for `meta.time_min * task.attempt`. - virtual-stain computes time_min before the init_only return so --init emits it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * payload bugfix * debug viscy predict * refactor: rename time_min to time_minutes for clarity Avoids confusion with "minimum time"; renames the resource payload key and Nextflow meta field consistently across both languages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * update iohub dep --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 15684a7 commit 12fdb93

14 files changed

Lines changed: 285 additions & 44 deletions

biahub/apply_inverse_transfer_function.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
sbatch_to_submitit,
2525
)
2626
from biahub.cli.utils import (
27+
echo_resources,
2728
get_submitit_cluster,
2829
yaml_to_model,
2930
)
@@ -112,7 +113,9 @@ def apply_inverse_transfer_function(
112113

113114
max_num_cpus = 16
114115
num_cpus, mem_per_cpu = wo_estimate_resources(list(input_shape), settings, max_num_cpus)
115-
click.echo(f"RESOURCES:{num_cpus} {num_cpus * mem_per_cpu}")
116+
mem_gb = num_cpus * mem_per_cpu
117+
time_minutes = 360
118+
echo_resources(num_cpus, mem_gb, time_minutes)
116119

117120
if init_only:
118121
click.echo(
@@ -130,9 +133,9 @@ def apply_inverse_transfer_function(
130133
# examples/submitit_debug_nextflow/2026-05-27-submitit-debug-nextflow-concerns.md
131134
slurm_args = {
132135
"slurm_job_name": "apply-inverse-transfer-function",
133-
"slurm_mem_per_cpu": f"{mem_per_cpu}G",
136+
"slurm_mem": f"{mem_gb}G",
134137
"slurm_cpus_per_task": num_cpus,
135-
"slurm_time": 60,
138+
"slurm_time": time_minutes,
136139
"slurm_partition": "cpu",
137140
}
138141

biahub/cli/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import json
12
import logging
23
import os
34

45
from pathlib import Path
56
from typing import Literal
67

8+
import click
79
import numpy as np
810
import yaml
911

@@ -14,6 +16,35 @@
1416
logger = logging.getLogger(__name__)
1517

1618

19+
def echo_resources(num_cpus: int, mem_gb: int, time_minutes: int) -> None:
20+
"""Emit the per-position resource request consumed by the Nextflow pipeline.
21+
22+
Every step CLI calls this from its ``--init`` path so there is a single
23+
source of truth for per-position CPU, memory, and wall-clock time. The
24+
Nextflow ``init_*`` process captures this line on stdout and
25+
``parse_resources`` (``nextflow/modules/common.nf``) reads the JSON payload
26+
to set the per-position task's ``cpus``/``memory``/``time`` directives. The
27+
same values also feed the CLI's own ``slurm_*`` submission args, so the
28+
SLURM fan-out and the Nextflow fan-out request identical resources.
29+
30+
A single JSON payload keeps the contract order-independent and extensible
31+
(new fields can be added without breaking the positional parsing).
32+
33+
Parameters
34+
----------
35+
num_cpus : int
36+
CPUs per position.
37+
mem_gb : int
38+
TOTAL memory per position in GB (not per-CPU).
39+
time_minutes : int
40+
Wall-clock budget per position in minutes.
41+
"""
42+
# Coerce to plain int: estimators may return numpy integers, which json
43+
# cannot serialize.
44+
payload = {"cpus": int(num_cpus), "mem_gb": int(mem_gb), "time_minutes": int(time_minutes)}
45+
click.echo("RESOURCES:" + json.dumps(payload))
46+
47+
1748
def get_submitit_cluster(
1849
local: bool = False,
1950
cluster: str | None = None,

biahub/deskew.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
sbatch_to_submitit,
2828
)
2929
from biahub.cli.utils import (
30+
echo_resources,
3031
estimate_resources,
3132
get_submitit_cluster,
3233
resolve_ome_zarr_version,
@@ -673,8 +674,12 @@ def deskew(
673674
_warn_pixel_size_mismatch(settings, input_position_dirpaths[0])
674675
input_shape, _ = _init_output_plate(input_position_dirpaths, output_dirpath, settings)
675676

676-
num_cpus, gb_ram = estimate_resources(shape=input_shape, ram_multiplier=8, max_num_cpus=16)
677-
click.echo(f"RESOURCES:{num_cpus} {num_cpus * gb_ram}")
677+
num_cpus, gb_ram_per_cpu = estimate_resources(
678+
shape=input_shape, ram_multiplier=8, max_num_cpus=16
679+
)
680+
mem_gb = num_cpus * gb_ram_per_cpu
681+
time_minutes = 60
682+
echo_resources(num_cpus, mem_gb, time_minutes)
678683

679684
if init_only:
680685
click.echo(f"Initialized {output_dirpath} ({len(input_position_dirpaths)} positions)")
@@ -694,10 +699,10 @@ def deskew(
694699

695700
slurm_args = {
696701
"slurm_job_name": "deskew",
697-
"slurm_mem_per_cpu": f"{gb_ram}G",
702+
"slurm_mem": f"{mem_gb}G",
698703
"slurm_cpus_per_task": num_cpus,
699704
"slurm_array_parallelism": 100, # process up to 100 positions at a time
700-
"slurm_time": 60,
705+
"slurm_time": time_minutes,
701706
"slurm_partition": "preempted",
702707
}
703708
if sbatch_filepath:

biahub/flat_field_correction.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
sbatch_to_submitit,
2121
)
2222
from biahub.cli.utils import (
23+
echo_resources,
2324
estimate_resources,
2425
get_submitit_cluster,
2526
resolve_ome_zarr_version,
@@ -161,8 +162,12 @@ def flat_field(
161162
)
162163

163164
T, C, Z, Y, X = input_shape
164-
num_cpus, gb_ram = estimate_resources(shape=input_shape, ram_multiplier=8, max_num_cpus=16)
165-
click.echo(f"RESOURCES:{num_cpus} {num_cpus * gb_ram}")
165+
num_cpus, gb_ram_per_cpu = estimate_resources(
166+
shape=input_shape, ram_multiplier=8, max_num_cpus=16
167+
)
168+
mem_gb = num_cpus * gb_ram_per_cpu
169+
time_minutes = 60
170+
echo_resources(num_cpus, mem_gb, time_minutes)
166171

167172
if init_only:
168173
click.echo(f"Initialized {output_dirpath} ({len(input_position_dirpaths)} positions)")
@@ -178,10 +183,10 @@ def flat_field(
178183

179184
slurm_args = {
180185
"slurm_job_name": "flat-field",
181-
"slurm_mem_per_cpu": f"{gb_ram}G",
186+
"slurm_mem": f"{mem_gb}G",
182187
"slurm_cpus_per_task": num_cpus,
183188
"slurm_array_parallelism": 100,
184-
"slurm_time": 360,
189+
"slurm_time": time_minutes,
185190
"slurm_partition": "cpu",
186191
}
187192

biahub/virtual_stain.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
sbatch_to_submitit,
2525
)
2626
from biahub.cli.utils import (
27+
echo_resources,
2728
get_submitit_cluster,
2829
resolve_ome_zarr_version,
2930
)
@@ -329,30 +330,31 @@ def virtual_stain(
329330

330331
# Timepoints are processed sequentially on a single GPU, so resource needs
331332
# are independent of dataset size.
332-
num_cpus, gb_ram = 16, 64
333-
click.echo(f"RESOURCES:{num_cpus} {gb_ram}")
333+
num_cpus, mem_gb = 16, 64
334+
# Generous wall-clock budget (minutes), assuming median TTA (4 rotations).
335+
# Each timepoint runs ~Z sliding windows along Z. Measured ~1.4 s/window
336+
# with TTA on this model; budget ~5 s/window (~3-4x margin for slower GPUs,
337+
# larger FOVs, and compute-bound runs) with a 60-minute floor. Computed
338+
# before the init_only return so --init emits it for the Nextflow pipeline.
339+
T, Z = input_shape[0], input_shape[2]
340+
seconds_per_window = 5
341+
time_minutes = int(np.ceil(max(60, T * Z * seconds_per_window / 60)))
342+
echo_resources(num_cpus, mem_gb, time_minutes)
334343

335344
if init_only:
336345
click.echo(f"Initialized {output_dirpath} ({len(input_position_dirpaths)} positions)")
337346
return
338347

339348
output_position_paths = utils.get_output_paths(input_position_dirpaths, output_dirpath)
340349

341-
T, Z = input_shape[0], input_shape[2]
342-
# Generous wall-clock budget (minutes), assuming median TTA (4 rotations).
343-
# Each timepoint runs ~Z sliding windows along Z. Measured ~1.4 s/window
344-
# with TTA on this model; budget ~5 s/window (~3-4x margin for slower GPUs,
345-
# larger FOVs, and compute-bound runs) with a 60-minute floor.
346-
seconds_per_window = 5
347-
slurm_time = int(np.ceil(max(60, T * Z * seconds_per_window / 60)))
348350
# Prepare SLURM arguments
349351
slurm_args = {
350352
"slurm_job_name": "virtual-stain",
351353
"slurm_gres": "gpu:1",
352-
"slurm_mem_per_cpu": f"{gb_ram}G",
354+
"slurm_mem": f"{mem_gb}G",
353355
"slurm_cpus_per_task": num_cpus,
354356
"slurm_array_parallelism": 20, # process up to 20 positions at a time
355-
"slurm_time": slurm_time,
357+
"slurm_time": time_minutes,
356358
"slurm_partition": "gpu",
357359
}
358360

nextflow/mantis-v2.nf

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,25 @@ nextflow.enable.dsl = 2
1616
// (in some pipelines the first step converts raw input to zarr). To reorder
1717
// steps, change where a step reads from here; the modules stay untouched.
1818
//
19-
// Flat-field → deskew → reconstruct is wired today. The remaining steps
20-
// (virtual-stain, track, assemble) arrive with their own PRs — follow the
19+
// Flat-field → deskew → reconstruct → virtual-stain is wired today. The
20+
// remaining steps (track, assemble) arrive with their own PRs — follow the
2121
// chaining below for the pattern.
2222
// ---------------------------------------------------------------------------
2323

24-
params.input = null // raw source — may not be a zarr store
25-
params.output = null // output directory for all step zarrs
26-
params.deskew_config = null
27-
params.flat_field_config = null
24+
params.input = null // raw source — may not be a zarr store
25+
params.output = null // output directory for all step zarrs
26+
params.deskew_config = null
27+
params.flat_field_config = null
2828
params.reconstruct_config = null
29-
params.biahub_project = null
30-
params.max_positions = 0
29+
params.virtual_stain_config = null
30+
params.biahub_project = null
31+
params.max_positions = 0
3132

3233
include { collect_positions; dataset_name } from './modules/common'
3334
include { deskew_wf } from './modules/deskew'
3435
include { flat_field_wf } from './modules/flat_field'
3536
include { reconstruct_wf } from './modules/reconstruct'
37+
include { virtual_stain_wf } from './modules/virtual_stain'
3638

3739
// Output directory layout for the reconstruction steps — single source of
3840
// truth. Each entry is a subdirectory under params.output where that step
@@ -44,7 +46,7 @@ DIRECTORY_LAYOUT = [
4446
flat_field : '0-flatfield',
4547
deskew : '1-deskew',
4648
reconstruct : '2-reconstruct',
47-
// virtual_stain : '3-virtual-stain',
49+
virtual_stain : '3-virtual-stain',
4850
// track : '4-track',
4951
// assemble : '5-assemble',
5052
]
@@ -56,6 +58,7 @@ workflow {
5658
if (!params.flat_field_config) error "Provide --flat_field_config"
5759
if (!params.deskew_config) error "Provide --deskew_config"
5860
if (!params.reconstruct_config) error "Provide --reconstruct_config"
61+
if (!params.virtual_stain_config) error "Provide --virtual_stain_config"
5962

6063
def ds = dataset_name()
6164
def out = params.output
@@ -91,4 +94,16 @@ workflow {
9194
reconstruct_output = "${out}/${DIRECTORY_LAYOUT.reconstruct}/${ds}.zarr"
9295

9396
reconstruct_done = reconstruct_wf(all_positions, reconstruct_input, reconstruct_output, params.reconstruct_config, reconstruct_trigger)
97+
98+
// ----- Virtual stain ----------------------------------------------------
99+
// Virtual staining runs cytoland (VisCy) prediction on the reconstructed
100+
// output and waits on reconstruct_done. A `viscy preprocess` step inside the
101+
// subworkflow computes the normalization statistics the model needs; which
102+
// source/target channels are used is set by the virtual-stain config, not
103+
// here.
104+
virtual_stain_trigger = reconstruct_done.done
105+
virtual_stain_input = reconstruct_output
106+
virtual_stain_output = "${out}/${DIRECTORY_LAYOUT.virtual_stain}/${ds}.zarr"
107+
108+
virtual_stain_done = virtual_stain_wf(all_positions, virtual_stain_input, virtual_stain_output, params.virtual_stain_config, virtual_stain_trigger)
94109
}

nextflow/modules/common.nf

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ def parse_resources(stdout_text, prefix = 'RESOURCES:') {
88
if (!matching) {
99
error "Expected a '${prefix}' line in command output but none was found. The underlying CLI may have failed."
1010
}
11-
def parts = matching.last().replace(prefix, '').trim().split(/\s+/)
12-
return [cpus: parts[0].toInteger(), mem_gb: parts[1].toInteger()]
11+
// The CLI emits a JSON payload (see biahub.cli.utils.echo_resources): cpus,
12+
// total mem_gb, and per-position time_minutes. Parsing JSON keeps the contract
13+
// order-independent and extensible.
14+
def payload = matching.last().replace(prefix, '').trim()
15+
def res = new groovy.json.JsonSlurper().parseText(payload)
16+
return [cpus: res.cpus as int, mem_gb: res.mem_gb as int, time_minutes: res.time_minutes as int]
1317
}
1418

1519
def slurm_log_dir(step_name) {

nextflow/modules/deskew.nf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ process run_deskew {
4646
maxForks 30
4747
cpus { meta.cpus }
4848
memory { "${meta.mem_gb} GB" }
49-
time { task.attempt == 1 ? '1h' : '2h' }
49+
time { "${meta.time_minutes * task.attempt} min" }
5050
maxRetries 1
5151
errorStrategy 'retry'
5252

nextflow/modules/flat_field.nf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ process run_flat_field {
4545
maxForks 30
4646
cpus { meta.cpus }
4747
memory { "${meta.mem_gb} GB" }
48-
time '1h'
48+
time { "${meta.time_minutes * task.attempt} min" }
4949
maxRetries 1
5050
errorStrategy 'retry'
5151

nextflow/modules/reconstruct.nf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ process run_apply_inv_tf {
9292
maxForks 30
9393
cpus { meta.cpus }
9494
memory { "${meta.mem_gb} GB" }
95-
time '6h'
95+
time { "${meta.time_minutes * task.attempt} min" }
9696
maxRetries 1
9797
errorStrategy 'retry'
9898

0 commit comments

Comments
 (0)