Coverage for src/precon3d/stitcher.py: 20%

158 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 03:50 +0000

1#!/usr/bin/env python3 

2# -*- coding: utf-8 -*- 

3""" 

4stitcher.py 

5 

6A unified CLI for stitching ZEISS CZI mosaics and tile-region arrays. 

7 

8Features: 

9 * Save “simple” downscaled mosaics (color or grayscale). 

10 * Stitch per-channel mosaics in parallel using a reference channel. 

11 * Optional flatfield normalization for mosaics. 

12 * Handle non-mosaic CZI files as tile-region arrays. 

13 * Commands for checking tile arrays and processing directories. 

14""" 

15 

16import time 

17from pathlib import Path 

18from typing import List 

19 

20import typer 

21import numpy as np 

22import skimage.io 

23import imageio 

24from PIL import Image 

25from rich.progress import Progress, SpinnerColumn, TextColumn 

26from joblib import Parallel, delayed 

27from aicspylibczi import CziFile 

28 

29# Local imports 

30import precon3d.mosaic_utils as mosaic_utils 

31import precon3d.utility as ut 

32import precon3d.factory as factory 

33from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

34from precon3d.custom_types import ( 

35 StitchingParams, 

36 GeneralAttrs, 

37 StitchingAttrs, 

38 FijiAttrs, 

39 NormalizationAttrs, 

40 TileStack, 

41 Mosaic, 

42 TilePosition, 

43) 

44 

45app = typer.Typer( 

46 cls=CustomCLIGroup, 

47 no_args_is_help=True, 

48 help="Stitch tiles and mosaics from ZEISS CZI files.", 

49) 

50 

51 

52def save_simple_color_mosaic( 

53 czi_file: Path, 

54 general_attrs: GeneralAttrs, 

55 stitching_attrs: StitchingAttrs, 

56 fiji_attrs: FijiAttrs, 

57) -> None: 

58 """ 

59 Save downscaled, per-channel color mosaics for each scene. 

60 

61 Args: 

62 czi_file (Path): Path to the CZI file. 

63 general_attrs (GeneralAttrs): General pipeline settings. 

64 stitching_attrs (StitchingAttrs): Stitching parameters. 

65 fiji_attrs (FijiAttrs): Fiji application settings. 

66 """ 

67 tilestack, fname = mosaic_utils.ai_tilestack(czi_file) 

68 bin_size = stitching_attrs.simple_mosaic_downscale_bin_size 

69 

70 for scene_idx in tilestack.scenes: 

71 stacks = mosaic_utils.simple_tilestack(tilestack, scene_idx, fname) 

72 for ts in stacks: 

73 # stitch the tiles using Fiji (per-channel) 

74 stitched = mosaic_utils.stitch_tilestack( 

75 ts, fiji_attrs.fiji_app, cleanup=True, tileconfig=ts.positions 

76 ) 

77 # prepare output directory and filename 

78 out_dir = ( 

79 general_attrs.output_directory 

80 / "Simple_Mosaic" 

81 / stitched.scene 

82 / stitched.channel 

83 ) 

84 out_dir.mkdir(parents=True, exist_ok=True) 

85 fname_out = ( 

86 f"{stitched.czi_fname}_{stitched.scene}_" 

87 f"{stitched.channel}_binsz{bin_size:02d}_mosaic.tif" 

88 ) 

89 # downscale 

90 img = stitched.data.squeeze() 

91 new_shape = ( 

92 img.shape[0] // bin_size, 

93 img.shape[1] // bin_size, 

94 img.shape[2], 

95 ) 

96 img_resized = skimage.transform.resize( 

97 img, new_shape, anti_aliasing=True 

98 ) 

99 # save 8-bit TIFF 

100 skimage.io.imsave( 

101 out_dir / fname_out, 

102 ut.convert_gray_to_8bit(img_resized.squeeze()), 

103 check_contrast=False, 

104 ) 

105 typer.echo(f'✓ Simple color mosaics from "{fname}" saved.') 

106 

107 

108def save_simple_mosaic( 

109 czi_file: Path, 

110 general_attrs: GeneralAttrs, 

111 stitching_attrs: StitchingAttrs, 

112 fiji_attrs: FijiAttrs, 

113) -> None: 

114 """ 

115 Save downscaled mosaics (color or grayscale) generated 

116 directly from acquisition stage positions. 

117 

118 Args: 

119 czi_file (Path): Path to the CZI file. 

120 general_attrs (GeneralAttrs): General pipeline settings. 

121 stitching_attrs (StitchingAttrs): Stitching parameters. 

122 fiji_attrs (FijiAttrs): Fiji application settings. 

123 """ 

124 ai_obj, _ = mosaic_utils.ai_tilestack(czi_file) 

125 dims = dict(ai_obj.dims) 

126 if dims.get("S", False): 

127 save_simple_color_mosaic( 

128 czi_file, general_attrs, stitching_attrs, fiji_attrs 

129 ) 

130 return 

131 

132 # grayscale path 

133 czi_mos, fname = mosaic_utils.ai_mosaic(czi_file) 

134 bin_size = stitching_attrs.simple_mosaic_downscale_bin_size 

135 

136 for scene_idx in czi_mos.scenes: 

137 czi_mos.set_scene(scene_idx) 

138 channels = dims.get("C", 1) 

139 channel_mosaics = [] 

140 for ch in range(channels): 

141 arr = czi_mos.get_image_data("MYXS", C=ch).squeeze() 

142 positions = czi_mos.reader.get_mosaic_tile_positions() 

143 tiles = [TilePosition(y=y, x=x) for y, x in positions] 

144 channel_mosaics.append( 

145 TileStack( 

146 data=arr, 

147 positions=tiles, 

148 czi_fname=fname, 

149 channel=czi_mos.channel_names[ch], 

150 scene=czi_mos.current_scene, 

151 scene_idx=czi_mos.current_scene_index, 

152 ) 

153 ) 

154 # downscale & save each channel 

155 for ts in channel_mosaics: 

156 out_dir = ( 

157 general_attrs.output_directory 

158 / "Simple_Mosaic" 

159 / ts.scene 

160 / ts.channel 

161 ) 

162 out_dir.mkdir(parents=True, exist_ok=True) 

163 fname_out = ( 

164 f"{ts.czi_fname}_{ts.scene}_{ts.channel}" 

165 f"_binsz{bin_size:02d}_mosaic.tif" 

166 ) 

167 img = ts.data.squeeze() 

168 pil = Image.fromarray(img) 

169 pil = pil.resize( 

170 (img.shape[1] // bin_size, img.shape[0] // bin_size), 

171 Image.Resampling.LANCZOS, 

172 ) 

173 skimage.io.imsave( 

174 out_dir / fname_out, 

175 ut.convert_gray_to_8bit(np.array(pil).squeeze()), 

176 check_contrast=False, 

177 ) 

178 typer.echo(f'✓ Simple grayscale mosaics from "{fname}" saved.') 

179 

180 

181def normalize_simple_tilestacks( 

182 stacks: List[TileStack], 

183 norm_attrs: NormalizationAttrs, 

184) -> List[TileStack]: 

185 """ 

186 Normalize each TileStack by its channel-specific flatfield. 

187 

188 Args: 

189 stacks (List[TileStack]): Tile stacks to normalize. 

190 norm_attrs (NormalizationAttrs): Flatfield settings. 

191 

192 Returns: 

193 List[TileStack]: New TileStacks with normalized data. 

194 

195 Raises: 

196 ValueError: If a required flatfield file is missing. 

197 """ 

198 parent = Path(norm_attrs.channel_flatfields_parent_directory) 

199 normalized = [] 

200 

201 for ts in stacks: 

202 ff_name = norm_attrs.channel_flatfields_filename.get(ts.channel) 

203 if ff_name is None: 

204 raise ValueError(f"No flatfield entry for channel '{ts.channel}'") 

205 ff_path = parent / ff_name 

206 if not ff_path.exists(): 

207 raise ValueError(f"Flatfield file '{ff_path}' not found") 

208 ff_img = imageio.v3.imread(ff_path).squeeze() 

209 if ff_img.ndim == 2: 

210 ff_img = ff_img[..., None] 

211 

212 data = ts.data 

213 norm_tiles = np.zeros_like(data, dtype=np.uint16) 

214 for i, tile in enumerate(data): 

215 norm_tiles[i] = mosaic_utils.normalize_images(tile, ff_img) 

216 normalized.append( 

217 TileStack( 

218 data=norm_tiles, 

219 positions=ts.positions, 

220 channel=ts.channel, 

221 czi_fname=ts.czi_fname, 

222 scene=ts.scene, 

223 scene_idx=ts.scene_idx, 

224 ) 

225 ) 

226 return normalized 

227 

228 

229def stitch_simple_tilestacks_with_reference_channel( 

230 stacks: List[TileStack], 

231 fiji_attrs: FijiAttrs, 

232 ref_channel: str, 

233) -> List[Mosaic]: 

234 """ 

235 Stitch a list of TileStacks, using one channel as reference. 

236 

237 The reference channel is stitched first, its tile positions 

238 are then applied to other channels to guarantee alignment. 

239 

240 Args: 

241 stacks (List[TileStack]): Per-channel TileStacks. 

242 fiji_attrs (FijiAttrs): Fiji application settings. 

243 ref_channel (str): Channel name to use as reference. 

244 

245 Returns: 

246 List[Mosaic]: Stitched mosaics for all channels. 

247 """ 

248 ref_ts = next((ts for ts in stacks if ts.channel == ref_channel), None) 

249 if ref_ts is None: 

250 raise ValueError(f"Reference channel '{ref_channel}' not found") 

251 

252 ref_mos = mosaic_utils.stitch_tilestack( 

253 ref_ts, fiji_attrs.fiji_app, cleanup=True 

254 ) 

255 out = [ref_mos] 

256 others = [ts for ts in stacks if ts.channel != ref_channel] 

257 

258 for ts in others: 

259 m = mosaic_utils.stitch_tilestack( 

260 ts, 

261 fiji_attrs.fiji_app, 

262 cleanup=True, 

263 tileconfig=ref_mos.positions, 

264 ) 

265 out.append(m) 

266 return out 

267 

268 

269def stitch_and_save_mosaic(czi_file: Path, params: StitchingParams) -> None: 

270 """ 

271 Full pipeline: per-scene tilestack → normalize → stitch → save. 

272 

273 Args: 

274 czi_file (Path): Path to a mosaic CZI file. 

275 params (StitchingParams): All pipeline parameters. 

276 """ 

277 tilestack, fname = mosaic_utils.ai_tilestack(czi_file) 

278 

279 for scene_idx in tilestack.scenes: 

280 stacks = mosaic_utils.simple_tilestack(tilestack, scene_idx, fname) 

281 

282 if params.normalization_attrs.use_flatfield: 

283 stacks = normalize_simple_tilestacks( 

284 stacks, params.normalization_attrs 

285 ) 

286 

287 mosaics = stitch_simple_tilestacks_with_reference_channel( 

288 stacks, 

289 params.fiji_attrs, 

290 params.stitching_attrs.stitch_reference_channel, 

291 ) 

292 # save 

293 for m in mosaics: 

294 # color check 

295 out_dir = ( 

296 params.general_attrs.output_directory 

297 / "Stitched" 

298 / m.scene 

299 / m.channel 

300 ) 

301 out_dir.mkdir(parents=True, exist_ok=True) 

302 fname_out = f"{m.czi_fname}_{m.scene}_{m.channel}_mosaic.tif" 

303 img = m.data 

304 if img.shape[-1] == 3: 

305 # RGB: convert BGR→RGB and gamma‐correct 

306 rgb = img[:, :, ::-1] 

307 rgb = skimage.exposure.adjust_gamma(rgb, 0.5) 

308 skimage.io.imsave( 

309 out_dir / fname_out, rgb, check_contrast=False 

310 ) 

311 else: 

312 skimage.io.imsave( 

313 out_dir / fname_out, 

314 ut.convert_gray_to_8bit(img.squeeze()), 

315 check_contrast=False, 

316 ) 

317 

318 

319def stitch_save_tilearray_by_idx( 

320 czi_file: Path, 

321 params: StitchingParams, 

322 grouping_idx: int, 

323) -> None: 

324 """ 

325 Stitch and save a tile‐region array CZI by grouping index. 

326 

327 Args: 

328 czi_file (Path): Path to a tile‐region CZI file. 

329 params (StitchingParams): Pipeline parameters. 

330 grouping_idx (int): Tile‐array group index. 

331 """ 

332 stacks = mosaic_utils.retrieve_tile_region_array_data(czi_file) 

333 grouped = mosaic_utils.group_tile_arrays(stacks, idx=grouping_idx) 

334 for ts in grouped: 

335 mos = mosaic_utils.stitch_tiles_from_positions(ts) 

336 mosaic_utils.save_stitched_mosaic( 

337 mos, params.general_attrs.output_directory 

338 ) 

339 

340 

341def start_stitcher_parallel( 

342 params: StitchingParams, 

343 ncores: int = 4, 

344 grouping_idx: int = 0, 

345) -> None: 

346 """ 

347 Process all CZI files in input directory in parallel. 

348 

349 Depending on `stitch_tiles` and whether the file is a mosaic, 

350 either runs full‐mosaic stitching or tile‐region array stitching. 

351 

352 Args: 

353 params (StitchingParams): Pipeline parameters. 

354 ncores (int): Number of parallel jobs. 

355 grouping_idx (int): Group index for tile‐region arrays. 

356 """ 

357 ext = params.general_attrs.file_extension 

358 if ext != ".czi": 

359 raise ValueError("Only .czi is supported") 

360 

361 inp = params.general_attrs.input_directory 

362 all_files = ut.sorted_files(inp, ext) 

363 out_stitched = params.general_attrs.output_directory / "Stitched" 

364 

365 if out_stitched.exists(): 

366 done = ut.sorted_files(params.general_attrs.output_directory, ".tif") 

367 todo = ut.remove_matching_filenames(all_files, done) 

368 else: 

369 todo = all_files 

370 

371 if params.stitching_attrs.stitch_tiles: 

372 first = todo[0] 

373 if CziFile(first).is_mosaic(): 

374 # full mosaic path 

375 Parallel(n_jobs=ncores)( 

376 delayed(stitch_and_save_mosaic)(f, params) for f in todo 

377 ) 

378 else: 

379 # tile‐region arrays 

380 Parallel(n_jobs=ncores)( 

381 delayed(stitch_save_tilearray_by_idx)(f, params, grouping_idx) 

382 for f in todo 

383 ) 

384 

385 

386@app.command( 

387 cls=CustomCLICommand, 

388 name="process_images", 

389 help="Stitch all CZI in parallel per config.", 

390) 

391def process_images( 

392 configfile: Path = typer.Argument(..., exists=True, help="YAML config."), 

393 ncores: int = typer.Option(4, help="Number of parallel jobs."), 

394 grouping_idx: int = typer.Option( 

395 0, help="Tile-array group index for non-mosaics." 

396 ), 

397) -> None: 

398 """ 

399 Read a YAML config and process all CZI files in the input directory. 

400 

401 Example: 

402 python stitcher.py process_images config.yml --ncores 8 

403 """ 

404 config = ut.read_config(configfile) 

405 params = factory.create_stitching_parameters(config) 

406 ut.current_date_and_time() 

407 typer.secho(f"*** Starting processing: {configfile.name} ***", fg="green") 

408 t0 = time.perf_counter() 

409 start_stitcher_parallel(params, ncores=ncores, grouping_idx=grouping_idx) 

410 dt = time.perf_counter() - t0 

411 typer.secho(f"Total time: {dt:.2f}s ({dt/60:.2f}min)", fg="green") 

412 

413 

414@app.command( 

415 cls=CustomCLICommand, 

416 name="stitch_czi", 

417 help="Stitch a single CZI with progress spinner.", 

418) 

419def stitch_czi( 

420 czifile: Path = typer.Argument(..., exists=True, help="CZI to stitch."), 

421 configfile: Path = typer.Argument(..., exists=True, help="YAML config."), 

422) -> None: 

423 """ 

424 Stitch one CZI (must be a mosaic) using the provided config. 

425 

426 Example: 

427 python stitcher.py stitch_czi sample.czi config.yml 

428 """ 

429 czi = czifile 

430 cfg = ut.read_config(configfile) 

431 params = factory.create_stitching_parameters(cfg) 

432 ut.current_date_and_time() 

433 typer.secho(f"*** Stitching {czi.name} ***", fg="cyan") 

434 t0 = time.perf_counter() 

435 with Progress( 

436 SpinnerColumn(), 

437 TextColumn("[progress.description]{task.description}"), 

438 transient=True, 

439 ) as prog: 

440 task = prog.add_task("Stitching...", total=None) 

441 stitch_and_save_mosaic(czi, params) 

442 prog.update(task, completed=True) 

443 dt = time.perf_counter() - t0 

444 typer.secho(f"Done in {dt:.2f}s ({dt/60:.2f}min)", fg="cyan") 

445 

446 

447@app.command( 

448 cls=CustomCLICommand, 

449 name="check_tilearray", 

450 help="Visualize tile-region arrays in a CZI.", 

451) 

452def check_tilearray( 

453 czi_path: Path = typer.Argument(..., exists=True, help="CZI to inspect."), 

454) -> None: 

455 """ 

456 Load tile-region array data and display a quick overview. 

457 

458 Example: 

459 python stitcher.py check_tilearray sample.czi 

460 """ 

461 stacks = mosaic_utils.tile_region_array_data(czi_path) 

462 flat = mosaic_utils.flatten_list(stacks) 

463 mosaic_utils.visualize_tilestacks(flat) 

464 

465 

466if __name__ == "__main__": 

467 app()