1- import logging
2- import math
3-
41from pathlib import Path
52
63import click
74import submitit
8- import yaml
95
106from iohub .ngff import open_ome_zarr
117from iohub .ngff .utils import create_empty_plate
12- from waveorder import sampling
138from waveorder .cli .apply_inverse_transfer_function import (
149 apply_inverse_transfer_function_single_position ,
1510 get_reconstruction_output_metadata ,
2924 sbatch_to_submitit ,
3025)
3126from 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
11833def _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
15575def 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
0 commit comments