Skip to content

Commit 972eeec

Browse files
Merge branch 'main' into refactor-track-rename
Bring PR #260 (track --init/--cluster + rename-channels + cellpose) up to date with main. main had already merged the deskew/flat-field/reconstruct (#255/#257/ #258), cytoland virtual-stain (#267/#271), and stitch-vendor (#238) refactors, which the refactor-* stack implemented in parallel; those subsystems are resolved in favour of main. Conflict resolution: - Took main's versions of deskew.py, flat_field_correction.py, apply_inverse_transfer_function.py, virtual_stain.py (cytoland rewrite, supersedes the stack's VisCy --init/--copy), cli/utils.py, and the deskew/flat_field/reconstruct/virtual_stain Nextflow modules + tests. - Took main's nextflow.config (adds the gpu label/queue that tracking.nf's run_track needs) and common.nf (JSON resources contract), and mantis-v2.nf. - pyproject.toml/uv.lock resolved to main (identical after taking iohub>=0.3.8). - Adopted main's JSON resources contract in track.py: --init now calls echo_resources(num_cpus, mem_gb, time_minutes) instead of the old space-separated 'RESOURCES:' line, so parse_resources (common.nf) works. - Preserved PR #260's unique work (tracking.nf, rename_channels.nf, the refactored/cellpose track.py, example_track_settings.yml).
2 parents 3412a21 + b011bca commit 972eeec

21 files changed

Lines changed: 1632 additions & 1027 deletions

biahub/apply_inverse_transfer_function.py

Lines changed: 49 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
import logging
2-
import math
3-
41
from pathlib import Path
52

63
import click
74
import submitit
8-
import yaml
95

106
from iohub.ngff import open_ome_zarr
117
from iohub.ngff.utils import create_empty_plate
12-
from waveorder import sampling
138
from waveorder.cli.apply_inverse_transfer_function import (
149
apply_inverse_transfer_function_single_position,
1510
get_reconstruction_output_metadata,
@@ -29,113 +24,39 @@
2924
sbatch_to_submitit,
3025
)
3126
from biahub.cli.utils import (
27+
echo_resources,
3228
get_submitit_cluster,
3329
yaml_to_model,
3430
)
3531

36-
logger = logging.getLogger(__name__)
37-
38-
39-
def _resolve_config(
40-
config_filepath: Path,
41-
reference_position_path: Path,
42-
output_dirpath: Path,
43-
) -> Path:
44-
"""Resolve pixel sizes from zarr metadata into the reconstruction config.
45-
46-
Reads voxel sizes from the input zarr scale metadata and injects them
47-
into the config's transfer_function sections when absent. Writes the
48-
resolved config next to the output zarr as ``reconstruct_resolved.yml``.
49-
50-
Returns the path to the resolved config.
51-
"""
52-
with open(config_filepath) as f:
53-
cfg = yaml.safe_load(f)
54-
55-
with open_ome_zarr(str(reference_position_path), mode="r") as ds:
56-
yx_pixel_size = float(ds.scale[-1])
57-
z_pixel_size = float(ds.scale[-3])
58-
59-
resolved_any = False
60-
for section in ("phase", "birefringence", "fluorescence"):
61-
tf = (cfg.get(section) or {}).get("transfer_function")
62-
if tf is not None:
63-
if "yx_pixel_size" not in tf or tf["yx_pixel_size"] is None:
64-
tf["yx_pixel_size"] = yx_pixel_size
65-
resolved_any = True
66-
logger.warning(
67-
"%s.transfer_function.yx_pixel_size not set in config; "
68-
"resolved %.4f from input zarr metadata",
69-
section,
70-
yx_pixel_size,
71-
)
72-
if "z_pixel_size" not in tf or tf["z_pixel_size"] is None:
73-
tf["z_pixel_size"] = z_pixel_size
74-
resolved_any = True
75-
logger.warning(
76-
"%s.transfer_function.z_pixel_size not set in config; "
77-
"resolved %.4f from input zarr metadata",
78-
section,
79-
z_pixel_size,
80-
)
81-
82-
if not resolved_any:
83-
click.echo("Pixel sizes already present in config; no resolution needed")
84-
85-
resolved_config = output_dirpath.parent / "reconstruct_resolved.yml"
86-
resolved_config.parent.mkdir(parents=True, exist_ok=True)
87-
with open(resolved_config, "w") as f:
88-
yaml.safe_dump(cfg, f, default_flow_style=False, sort_keys=False)
89-
90-
click.echo(f"Resolved config written to {resolved_config}")
91-
return resolved_config
92-
93-
94-
def _upsampled_zyx(
95-
settings: ReconstructionSettings,
96-
zyx_shape: tuple[int, int, int],
97-
) -> tuple[int, int, int]:
98-
"""Return the Nyquist-upsampled ZYX shape used during TF computation."""
99-
Z, Y, X = zyx_shape
100-
if settings.phase is not None:
101-
tf = settings.phase.transfer_function
102-
trans_nyq = sampling.transverse_nyquist(
103-
tf.wavelength_illumination,
104-
tf.numerical_aperture_illumination,
105-
tf.numerical_aperture_detection,
106-
)
107-
axial_nyq = sampling.axial_nyquist(
108-
tf.wavelength_illumination,
109-
tf.numerical_aperture_detection,
110-
tf.index_of_refraction_media,
111-
)
112-
yx_factor = math.ceil(tf.yx_pixel_size / trans_nyq)
113-
z_factor = math.ceil(tf.z_pixel_size / axial_nyq)
114-
Z, Y, X = Z * z_factor, Y * yx_factor, X * yx_factor
115-
return Z, Y, X
116-
11732

11833
def _init_output_plate(
11934
input_position_dirpaths: list[Path],
12035
output_dirpath: Path,
12136
config_filepath: Path,
122-
) -> tuple[tuple[int, int, int, int, int], list[str], Path]:
37+
settings: ReconstructionSettings,
38+
) -> tuple[tuple[int, int, int, int, int], list[str]]:
12339
"""Create the empty reconstruction output plate.
12440
125-
Resolves pixel sizes from zarr metadata, creates the output store, and
126-
copies per-position metadata from the input plate.
41+
``settings`` is the validated ReconstructionSettings loaded by the caller.
42+
Output shape/scale/version/channel names are derived by waveorder from the
43+
config, and per-position metadata is copied from the input plate.
12744
128-
Returns (input_shape, channel_names, resolved_config_path).
129-
"""
130-
resolved_config = _resolve_config(
131-
config_filepath, input_position_dirpaths[0], output_dirpath
132-
)
45+
create_empty_plate is idempotent: re-running with the same positions is a
46+
no-op, and new positions get appended. Safe to call from both the init
47+
orchestrator and from per-position runs.
13348
49+
Returns the input (T, C, Z, Y, X) shape and output channel names.
50+
"""
13451
with open_ome_zarr(str(input_position_dirpaths[0]), mode="r") as ds:
13552
input_shape = ds.data.shape
13653

54+
# Pixel sizes come from the config (the source of truth). waveorder's
55+
# get_reconstruction_output_metadata already emits a PixelSizeMismatchWarning
56+
# when the config pixel sizes disagree (>5%) with the input zarr scale, so we
57+
# do not warn again here.
13758
output_metadata = get_reconstruction_output_metadata(
138-
input_position_dirpaths[0], resolved_config
59+
input_position_dirpaths[0], config_filepath
13960
)
14061
output_metadata.pop("plate_metadata", None)
14162
channel_names = output_metadata["channel_names"]
@@ -148,18 +69,15 @@ def _init_output_plate(
14869
metadata_sources=input_plate,
14970
)
15071

151-
click.echo(f"Created {output_dirpath} ({len(input_position_dirpaths)} positions)")
152-
return input_shape, channel_names, resolved_config
72+
return input_shape, channel_names
15373

15474

15575
def apply_inverse_transfer_function(
15676
input_position_dirpaths: list[Path],
15777
transfer_function_dirpath: Path,
15878
config_filepath: Path,
15979
output_dirpath: Path,
160-
num_processes: int = 1,
16180
sbatch_filepath: Path | None = None,
162-
local: bool = False,
16381
cluster: str = "slurm",
16482
monitor: bool = True,
16583
init_only: bool = False,
@@ -176,12 +94,8 @@ def apply_inverse_transfer_function(
17694
Path to YAML reconstruction config.
17795
output_dirpath : Path
17896
Path to output zarr.
179-
num_processes : int
180-
Number of parallel processes per position.
18197
sbatch_filepath : Path, optional
18298
SBATCH file with slurm parameter overrides.
183-
local : bool
184-
Legacy flag — use ``cluster='local'`` instead.
18599
cluster : str
186100
Execution cluster: 'slurm', 'local', or 'debug'.
187101
monitor : bool
@@ -192,69 +106,36 @@ def apply_inverse_transfer_function(
192106
output_dirpath = Path(output_dirpath)
193107
slurm_out_path = output_dirpath.parent / "slurm_output"
194108

195-
if init_only:
196-
input_shape, _, resolved_config = _init_output_plate(
197-
input_position_dirpaths, output_dirpath, config_filepath
198-
)
199-
T, C, Z, Y, X = input_shape
200-
settings = yaml_to_model(resolved_config, ReconstructionSettings)
201-
202-
num_cpus, mem_per_cpu = wo_estimate_resources(list(input_shape), settings, 1)
203-
click.echo(f"RESOURCES:{num_cpus} {num_cpus * mem_per_cpu}")
204-
205-
uZ, uY, uX = _upsampled_zyx(settings, (Z, Y, X))
206-
tf_cpus, tf_mem = wo_estimate_resources([1, 1, uZ, uY, uX], settings, 1)
207-
click.echo(f"TF_RESOURCES:{tf_cpus} {tf_cpus * tf_mem}")
208-
return
209-
210-
output_metadata = get_reconstruction_output_metadata(
211-
input_position_dirpaths[0], config_filepath
109+
settings = yaml_to_model(config_filepath, ReconstructionSettings)
110+
input_shape, channel_names = _init_output_plate(
111+
input_position_dirpaths, output_dirpath, config_filepath, settings
212112
)
213-
channel_names = output_metadata["channel_names"]
214113

215-
resolved_cluster = get_submitit_cluster(local=local, cluster=cluster)
114+
max_num_cpus = 16
115+
num_cpus, mem_per_cpu = wo_estimate_resources(list(input_shape), settings, max_num_cpus)
116+
mem_gb = num_cpus * mem_per_cpu
117+
time_minutes = 360
118+
echo_resources(num_cpus, mem_gb, time_minutes)
216119

217-
# --cluster debug: apply inverse TF per-position in-process.
218-
# Nextflow already handles per-position fan-out and resource scheduling,
219-
# so the CLI runs work in-process via submitit's DebugExecutor rather than
220-
# submitting its own SLURM jobs. See also:
221-
# examples/submitit_debug_nextflow/2026-05-27-submitit-debug-nextflow-concerns.md
222-
if resolved_cluster == "debug":
223-
for pos_path in input_position_dirpaths:
224-
output_position = output_dirpath / Path(*pos_path.parts[-3:])
225-
apply_inverse_transfer_function_single_position(
226-
pos_path,
227-
transfer_function_dirpath,
228-
config_filepath,
229-
output_position,
230-
num_processes,
231-
channel_names,
232-
)
233-
click.echo(f"Apply-inv-tf done: {'/'.join(pos_path.parts[-3:])}")
120+
if init_only:
121+
click.echo(
122+
f"Created {output_dirpath} ({len(input_position_dirpaths)} positions, "
123+
f"{len(settings.output_channel_names)} output channels)"
124+
)
234125
return
235126

236-
# SLURM / local: create output plate and fan out via submitit
237-
output_metadata.pop("plate_metadata", None)
238-
input_plate = Path(input_position_dirpaths[0]).parents[2]
239-
create_empty_plate(
240-
store_path=output_dirpath,
241-
position_keys=[p.parts[-3:] for p in input_position_dirpaths],
242-
**output_metadata,
243-
metadata_sources=input_plate,
244-
)
245-
246-
settings = yaml_to_model(config_filepath, ReconstructionSettings)
247-
with open_ome_zarr(str(input_position_dirpaths[0]), mode="r") as ds:
248-
input_shape = ds.data.shape
249-
num_cpus, gb_ram_per_cpu = wo_estimate_resources(
250-
list(input_shape), settings, num_processes
251-
)
127+
resolved_cluster = get_submitit_cluster(cluster=cluster)
252128

129+
# Fan out one job per position via submitit. With --cluster debug, submitit's
130+
# DebugExecutor runs the work in-process (the slurm_* parameters are ignored):
131+
# Nextflow already handles per-position fan-out and resource scheduling, so the
132+
# CLI must NOT submit its own SLURM jobs. See also:
133+
# examples/submitit_debug_nextflow/2026-05-27-submitit-debug-nextflow-concerns.md
253134
slurm_args = {
254135
"slurm_job_name": "apply-inverse-transfer-function",
255-
"slurm_mem_per_cpu": f"{gb_ram_per_cpu}G",
136+
"slurm_mem": f"{mem_gb}G",
256137
"slurm_cpus_per_task": num_cpus,
257-
"slurm_time": 360,
138+
"slurm_time": time_minutes,
258139
"slurm_partition": "cpu",
259140
}
260141

@@ -276,7 +157,7 @@ def apply_inverse_transfer_function(
276157
transfer_function_dirpath,
277158
config_filepath,
278159
output_dirpath / Path(*pos_path.parts[-3:]),
279-
num_processes,
160+
num_cpus,
280161
channel_names,
281162
)
282163
)
@@ -287,6 +168,16 @@ def apply_inverse_transfer_function(
287168
with log_path.open("w") as log_file:
288169
log_file.write("\n".join(job_ids))
289170

171+
# submitit's DebugExecutor is lazy: .submit() wraps the callable in a DebugJob
172+
# but execution only happens when .wait()/.done()/.result() is called. Run each
173+
# one in the foreground and stream progress; monitor's async polling UI is
174+
# pointless against synchronous in-process jobs.
175+
if resolved_cluster == "debug":
176+
for job, path in zip(jobs, input_position_dirpaths, strict=True):
177+
job.wait()
178+
click.echo(f"Apply-inv-tf complete: {path}")
179+
return
180+
290181
if monitor:
291182
monitor_jobs(jobs, input_position_dirpaths)
292183

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,

0 commit comments

Comments
 (0)