Coverage for src/precon3d/czi_info.py: 87%

187 statements  

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

1"""Module for extracting metadata and tile information from CZI files. 

2 

3This module provides CLI commands and functions to process `.czi` files, 

4extract metadata, check dimensions, and save tiles. 

5""" 

6 

7# Standard library imports 

8from datetime import datetime 

9from pathlib import Path 

10from typing import Final, Tuple, List, NamedTuple, Union, Optional 

11import csv 

12import xml.etree.ElementTree as ET 

13import math 

14 

15# Third-party library imports 

16import numpy as np 

17import pytz 

18from dateutil import parser 

19import typer 

20from aicspylibczi import CziFile 

21 

22# Local imports 

23import precon3d.mosaic_utils 

24import precon3d.utility as ut 

25from precon3d.custom_types import * 

26from precon3d._my_typer_cli import CustomCLIGroup, CustomCLICommand 

27 

28# CLI setup 

29app = typer.Typer( 

30 cls=CustomCLIGroup, 

31 no_args_is_help=True, 

32 short_help="Access information about the CZI file.", 

33) 

34 

35 

36@app.command( 

37 cls=CustomCLICommand, 

38 name="czidims", 

39 short_help="Get the dimensions of the CZI file or folder.", 

40) 

41def czidims(czi_path: Path) -> bool: 

42 """Check that one or many CZI files have consistent dimensions. 

43 

44 Args: 

45 czi_path (Path): Path to a `.czi` file or directory of `.czi` files. 

46 

47 Returns: 

48 bool: True if a single file is valid or if all files in a directory share the same dimensions. 

49 False if two files in the directory differ in shape. 

50 

51 Raises: 

52 FileNotFoundError: If `czi_path` is a directory but contains no `.czi` files. 

53 ValueError: If `czi_path` is neither a file nor a directory. 

54 """ 

55 if czi_path.is_file(): 

56 dims = get_dims(czi_path) 

57 print(f"{czi_path.stem} dimensions: {dims}") 

58 return True 

59 

60 if czi_path.is_dir(): 

61 czi_files = ut.sorted_files(czi_path, file_extension=".czi") 

62 if not czi_files: 

63 raise FileNotFoundError(f"No .czi files found in directory: {czi_path}") 

64 

65 ref_dims = get_dims(czi_files[0]) 

66 print(f"{czi_files[0].stem} dimensions: {ref_dims}") 

67 

68 for file_path in czi_files[1:]: 

69 dims = get_dims(file_path) 

70 print(f"{file_path.stem} dimensions: {dims}") 

71 if dims != ref_dims: 

72 print("Dimensions are not consistent") 

73 return False 

74 

75 print("Dimensions are consistent") 

76 return True 

77 

78 raise ValueError(f"Path is not a file or directory: {czi_path}") 

79 

80 

81def get_dims(czi_file_path: Path) -> dict[str, int]: 

82 """Return the dimension‐shape mapping for a single CZI file. 

83 

84 Args: 

85 czi_file_path (Path): Path to the `.czi` file. 

86 

87 Returns: 

88 dict[str, int]: A mapping from dimension names (e.g., 'X', 'Y', 'Z', 'C', 'T') to their respective sizes. 

89 """ 

90 return CziFile(czi_file_path).get_dims_shape() 

91 

92 

93@app.command( 

94 cls=CustomCLICommand, 

95 name="acquisition_date_and_time", 

96 short_help="Get the acquisition date and time from the CZI file.", 

97) 

98def acquisition_date_and_time(czi_file_path: Path) -> str: 

99 """Retrieve the acquisition date and time for the CZI file. 

100 

101 Args: 

102 czi_file_path (Path): Path to the `.czi` file. 

103 

104 Returns: 

105 str: Formatted acquisition date and time string. 

106 """ 

107 datetime_str = ( 

108 CziFile(czi_file_path).meta.find(".//Image//AcquisitionDateAndTime").text 

109 ) 

110 dt = parser.isoparse(datetime_str) 

111 formatted_datetime = dt.strftime("%Y-%m-%d %H:%M:%S") 

112 typer.echo(formatted_datetime) 

113 return formatted_datetime 

114 

115 

116@app.command( 

117 cls=CustomCLICommand, 

118 name="simple_metadata", 

119 short_help="Retrieve simple metadata from the CZI file.", 

120) 

121def simple_metadata(czi_file_path: Path) -> CZIMetadata: 

122 """Retrieve simple metadata from the CZI file. 

123 

124 Args: 

125 czi_file_path (Path): Path to the `.czi` file. 

126 

127 Raises: 

128 TypeError: If the file is not a mosaic CZI file. 

129 

130 Returns: 

131 CZIMetadata: Named tuple containing the metadata. 

132 """ 

133 if not isinstance(czi_file_path, Path): 

134 czi_file_path = Path(czi_file_path) 

135 

136 czi = CziFile(czi_file_path) 

137 if not czi.is_mosaic(): 

138 raise TypeError("Only CZI mosaic files are supported") 

139 

140 metadata = czi.meta 

141 pixel_type = czi.pixel_type 

142 

143 all_channels = metadata.findall(".//Image//Dimensions//Channels//Channel") 

144 all_scenes = metadata.findall(".//Image//Dimensions//S//Scenes//Scene") 

145 

146 simple_czi_metadata = CZIMetadata( 

147 filename=czi_file_path.stem, 

148 author=metadata.find(".//Document//UserName").text, 

149 date_created=metadata.find(".//Image//AcquisitionDateAndTime").text, 

150 dims=czi.dims, 

151 dims_shape=czi.get_dims_shape(), 

152 scene_names=[scene.get("Name") for scene in all_scenes], 

153 scene_metadata=scene_metadata(czi_file_path), 

154 scene_shapes_are_consistent=czi.shape_is_consistent, 

155 channel_names=[channel.get("Name") for channel in all_channels], 

156 channel_metadata=channel_metadata(czi_file_path), 

157 tile_width=metadata.find(".//SampleHolder//TileDimension//Width").text, 

158 tile_height=metadata.find(".//SampleHolder//TileDimension//Height").text, 

159 tile_overlap=metadata.find(".//SampleHolder//Overlap").text, 

160 tile_anchor_mode=metadata.find(".//SampleHolder//TileRegionAnchorMode").text, 

161 tile_scan_mode=metadata.find(".//SampleHolder//ScanMode").text, 

162 microscope_name=metadata.find(".//Instrument//Microscopes//Microscope").get( 

163 "Name" 

164 ), 

165 microscope_type=metadata.find( 

166 ".//Instrument//Microscopes//Microscope//Type" 

167 ).text, 

168 camera_name=metadata.find( 

169 ".//Instrument//Detectors//Detector//Manufacturer//Model" 

170 ).text, 

171 camera_adapter=metadata.find( 

172 ".//Instrument//Detectors//Adapter//Manufacturer//Model" 

173 ).text, 

174 camera_pixel_size=metadata.find( 

175 ".//Scaling//AutoScaling//CameraPixelDistance" 

176 ).text, 

177 camera_pixel_unit=metadata.find( 

178 ".//Scaling//Items//Distance//DefaultUnitFormat" 

179 ).text, 

180 objective_name=metadata.find(".//Objective//Manufacturer//Model").text, 

181 objective_magnification=metadata.find( 

182 ".//Objectives//Objective//NominalMagnification" 

183 ).text, 

184 effective_pixel_size=metadata.find(".//Scaling//Items//Distance//Value").text, 

185 color_mode=str(pixel_type), 

186 ) 

187 

188 typer.echo(simple_czi_metadata) 

189 return simple_czi_metadata 

190 

191 

192def average_slice_heights(all_czi_file_paths: List[Path]) -> np.ndarray: 

193 """Calculate and save the average slice heights for each scene in CZI files. 

194 

195 Args: 

196 all_czi_file_paths (List[Path]): List of paths to `.czi` files. 

197 

198 Returns: 

199 np.ndarray: Array of average slice heights for each scene. 

200 """ 

201 avg_slice_heights = [] 

202 csv_fname_root = "raw_slice_heights.csv" 

203 row_number = 1 

204 

205 print(f'{" Reading the files: ":*^28}') 

206 for each_czi_path in all_czi_file_paths: 

207 print(each_czi_path.stem) 

208 

209 czi = CziFile(each_czi_path) 

210 if czi.is_mosaic(): 

211 print("Found a mosaic CZI file with scene support points.") 

212 all_scene_sp = scene_support_points(each_czi_path) 

213 else: 

214 print("Found a tilearray CZI file with global support points.") 

215 all_scene_sp = scene_support_points_global(each_czi_path) 

216 

217 # If no support points found in metadata 

218 if len(all_scene_sp) == 0: 

219 print(f"No scene support points found in {each_czi_path.stem}. Skipping.") 

220 raise TypeError("CZI file must contain support points") 

221 

222 for each_scene_sp in all_scene_sp: 

223 czi_fname = each_scene_sp.czi_fname 

224 scene_name = each_scene_sp.scene 

225 

226 # no z_heights scenario 

227 if each_scene_sp.z_heights.size == 0: 

228 average_z_height = float(each_scene_sp.z) 

229 else: 

230 average_z_height = np.mean(each_scene_sp.z_heights) 

231 

232 avg_slice_heights.append(average_z_height) 

233 

234 with open( 

235 f"{each_czi_path.parent.joinpath(scene_name)}_{csv_fname_root}", 

236 "a", 

237 newline="", 

238 encoding="utf-8", 

239 ) as csvfile: 

240 sp_writer = csv.writer(csvfile) 

241 if row_number == 1: 

242 sp_writer.writerow( 

243 [ 

244 "File name", 

245 "Scene name", 

246 "Average height (um)", 

247 "All heights (um)", 

248 ] 

249 ) 

250 sp_writer.writerow( 

251 [ 

252 czi_fname, 

253 scene_name, 

254 average_z_height, 

255 each_scene_sp.z_heights, 

256 ] 

257 ) 

258 

259 row_number += 1 

260 

261 return np.array(avg_slice_heights) 

262 

263 

264def scene_support_points(czi_file_path: Path) -> List[SceneSupportPoints]: 

265 """Extract scene support points data from CZI metadata. 

266 

267 Args: 

268 czi_file_path (Path): Path to the `.czi` file. 

269 

270 Returns: 

271 List[SceneSupportPoints]: List of scene support points data. 

272 """ 

273 file_basename = czi_file_path.stem 

274 metadata = CziFile(czi_file_path).meta 

275 

276 all_scene_sp = [] 

277 for each_scene in metadata.findall(".//TileRegion"): 

278 if each_scene.find(".//IsUsedForAcquisition").text == "true": 

279 scene_name = each_scene.attrib["Name"] 

280 scene_z = each_scene.find(".//Z").text 

281 

282 sp_xy = [] 

283 sp_z = [] 

284 for each_sp in each_scene.findall(".//SupportPoints//SupportPoint"): 

285 sp_xy.append( 

286 TilePosition(x=each_sp.find("X").text, y=each_sp.find("Y").text) 

287 ) 

288 sp_z.append(each_sp.find("Z").text) 

289 

290 all_scene_sp.append( 

291 SceneSupportPoints( 

292 z=scene_z, 

293 z_heights=np.array(sp_z, dtype="float32"), 

294 xy_positions=sp_xy, 

295 czi_fname=file_basename, 

296 scene=scene_name, 

297 ) 

298 ) 

299 

300 return all_scene_sp 

301 

302 

303def scene_support_points_global( 

304 czi_file_path: Path, 

305) -> List[SceneSupportPoints]: 

306 """Extract global scene support points data from CZI metadata. 

307 

308 Args: 

309 czi_file_path (Path): Path to the `.czi` file. 

310 

311 Returns: 

312 List[SceneSupportPoints]: List of global scene support points data. 

313 """ 

314 file_basename = czi_file_path.stem 

315 metadata = CziFile(czi_file_path).meta 

316 

317 all_scene_sp = [] 

318 for each_scene in metadata.findall(".//SampleHolder//Template"): 

319 scene_name = each_scene.attrib["Name"] 

320 

321 sp_xy = [] 

322 sp_z = [] 

323 for each_sp in each_scene.findall(".//SupportPoints//SupportPoint"): 

324 sp_xy.append( 

325 TilePosition(x=each_sp.find("X").text, y=each_sp.find("Y").text) 

326 ) 

327 sp_z.append(each_sp.find("Z").text) 

328 

329 sp_all_z = np.array(sp_z, dtype="float32") 

330 all_scene_sp.append( 

331 SceneSupportPoints( 

332 z=np.mean(sp_all_z), 

333 z_heights=sp_all_z, 

334 xy_positions=sp_xy, 

335 czi_fname=file_basename, 

336 scene=scene_name, 

337 ) 

338 ) 

339 

340 return all_scene_sp 

341 

342 

343def channel_metadata(czi_file_path: Path) -> List[ChannelMetadata]: 

344 """Extract channel metadata from CZI files. 

345 

346 Args: 

347 czi_file_path (Path): Path to the `.czi` file. 

348 

349 Returns: 

350 List[ChannelMetadata]: List of channel metadata. 

351 """ 

352 czi = CziFile(czi_file_path) 

353 metadata = czi.meta 

354 

355 all_channels = metadata.findall(".//Image//Dimensions//Channels//Channel") 

356 all_channel_metadata = [] 

357 for each_channel in all_channels: 

358 illumination_wavelength_element = each_channel.find( 

359 "IlluminationWavelength/SinglePeak" 

360 ) 

361 illumination_wavelength = ( 

362 int(illumination_wavelength_element.text) 

363 if illumination_wavelength_element is not None 

364 else None 

365 ) 

366 

367 illumination_wavelength_range_element = each_channel.find( 

368 "IlluminationWavelength/Ranges" 

369 ) 

370 illumination_wavelength_range = ( 

371 illumination_wavelength_range_element.text 

372 if illumination_wavelength_range_element is not None 

373 else None 

374 ) 

375 

376 each_channel_metadata = ChannelMetadata( 

377 channel_name=each_channel.get("Name"), 

378 mode=each_channel.find("Fluor").text, 

379 exposure_time=int(each_channel.find("ExposureTime").text), 

380 reflector=each_channel.find("Reflector").text, 

381 contrast=each_channel.find("ContrastMethod").text, 

382 pixel_type=each_channel.find("PixelType").text, 

383 pixel_bit_count=int(each_channel.find("ComponentBitCount").text), 

384 illumination_wavelength=illumination_wavelength, 

385 illumination_wavelength_range=illumination_wavelength_range, 

386 illumination_intensity=each_channel.find( 

387 "LightSourcesSettings/LightSourceSettings/Intensity" 

388 ).text, 

389 detector_binning=each_channel.find("DetectorSettings/Binning").text, 

390 ) 

391 all_channel_metadata.append(each_channel_metadata) 

392 

393 return all_channel_metadata 

394 

395 

396def scene_metadata(czi_file_path: Path) -> List[SceneMetadata]: 

397 """Extract scene metadata from CZI files. 

398 

399 Args: 

400 czi_file_path (Path): Path to the `.czi` file. 

401 

402 Returns: 

403 List[SceneMetadata]: List of scene metadata. 

404 """ 

405 czi = CziFile(czi_file_path) 

406 metadata = czi.meta 

407 

408 active_tile_regions = [ 

409 tile_region 

410 for tile_region in metadata.findall(".//SampleHolder//TileRegions//TileRegion") 

411 if tile_region.find("IsUsedForAcquisition").text == "true" 

412 ] 

413 

414 all_bbox = czi.get_all_mosaic_scene_bounding_boxes() 

415 

416 all_scenes_metadata = [] 

417 for idx, each_tile_region in enumerate(active_tile_regions): 

418 each_scene_metadata = SceneMetadata( 

419 scene_name=each_tile_region.get("Name"), 

420 scene_index=idx, 

421 bounding_box=BoundingBox( 

422 h=all_bbox[idx].h, 

423 w=all_bbox[idx].w, 

424 x=all_bbox[idx].x, 

425 y=all_bbox[idx].y, 

426 ), 

427 n_columns=int(each_tile_region.find("Columns").text), 

428 n_rows=int(each_tile_region.find("Rows").text), 

429 contour_size=each_tile_region.find("ContourSize").text, 

430 center_position=each_tile_region.find("CenterPosition").text, 

431 ) 

432 all_scenes_metadata.append(each_scene_metadata) 

433 

434 return all_scenes_metadata 

435 

436 

437@app.command( 

438 cls=CustomCLICommand, 

439 name="metadata_as_xml", 

440 short_help="Save xml of metadata.", 

441) 

442def metadata_as_xml(czi_file_path: Path) -> Path: 

443 """Save CZI metadata as an XML file. 

444 

445 Args: 

446 czi_file_path (Path): Path to the `.czi` file. 

447 

448 Returns: 

449 Path: Path to the saved XML file. 

450 """ 

451 directory_path = czi_file_path.parent 

452 czi_file_metadata = CziFile(czi_file_path).meta 

453 output_xml_path = directory_path.joinpath(czi_file_path.stem + "_metadata.xml") 

454 

455 xml_string = ET.tostring(czi_file_metadata, encoding="unicode", method="xml") 

456 with open(output_xml_path, "w", encoding="utf-8") as f: 

457 f.write(xml_string) 

458 

459 return output_xml_path 

460 

461 

462@app.command( 

463 cls=CustomCLICommand, 

464 name="save_tiles", 

465 short_help="Save all tiles from the CZI files in the specified directory.", 

466) 

467def save_tiles(czi_input_dir: Path, output_directory: Path): 

468 """Save tiles from CZI files as TIFF images. 

469 

470 Args: 

471 czi_input_dir (Path): Directory containing `.czi` files. 

472 output_directory (Path): Directory to save the extracted tiles. 

473 """ 

474 print(f"\tExtracting tiles from .czi files in {czi_input_dir}\n") 

475 

476 czi_filepaths = ut.sorted_files(directory=czi_input_dir, file_extension=".czi") 

477 

478 for each_czi_file in czi_filepaths: 

479 if CziFile(each_czi_file).is_mosaic(): 

480 tilestack, tilestack_fname = precon3d.mosaic_utils.ai_tilestack( 

481 each_czi_file 

482 ) 

483 

484 for each_scene_index in tilestack.scenes: 

485 tilestack_list = precon3d.mosaic_utils.simple_tilestack( 

486 tilestack, each_scene_index, tilestack_fname 

487 ) 

488 

489 for each_tilestack in tilestack_list: 

490 precon3d.mosaic_utils.save_tilestack( 

491 tilestack=each_tilestack, 

492 output_directory=output_directory, 

493 ) 

494 else: 

495 tilestack_list = precon3d.mosaic_utils.tile_region_array_data(each_czi_file) 

496 # breakpoint() 

497 # for each_tilestack_scene_list in tilestack_list: 

498 # for each_tilestack_channel in each_tilestack_scene_list: 

499 # for each_tilestack_channel in tilestack_list: 

500 # breakpoint() 

501 merged_tilestack_channel = precon3d.mosaic_utils.merge_tile_arrays( 

502 tilestack_list 

503 ) 

504 precon3d.mosaic_utils.save_tilestack( 

505 tilestack=merged_tilestack_channel, 

506 output_directory=output_directory, 

507 ) 

508 

509 print(f'\tTiles from "{each_czi_file.stem}" were saved.') 

510 

511 

512@app.command( 

513 cls=CustomCLICommand, 

514 name="save_tile_by_idx", 

515 short_help="Save single tile based on index from the CZI files in the specified directory.", 

516) 

517def save_tile_by_idx(czi_input_dir: Path, tile_idx: int, output_directory: Path): 

518 """Save tiles from CZI files as TIFF images. 

519 

520 Args: 

521 czi_input_dir (Path): Directory containing `.czi` files. 

522 output_directory (Path): Directory to save the extracted tiles. 

523 """ 

524 print(f"\tExtracting tiles from .czi files in {czi_input_dir}\n") 

525 

526 czi_filepaths = ut.sorted_files(directory=czi_input_dir, file_extension=".czi") 

527 

528 for each_czi_file in czi_filepaths: 

529 if CziFile(each_czi_file).is_mosaic(): 

530 tilestack, tilestack_fname = precon3d.mosaic_utils.ai_tilestack( 

531 each_czi_file 

532 ) 

533 

534 for each_scene_index in tilestack.scenes: 

535 tilestack_list = precon3d.mosaic_utils.simple_tilestack( 

536 tilestack, each_scene_index, tilestack_fname 

537 ) 

538 

539 for each_tilestack_channel in tilestack_list: 

540 precon3d.mosaic_utils.save_tile_by_idx( 

541 tilestack=each_tilestack_channel, 

542 tile_idx=tile_idx, 

543 output_directory=output_directory, 

544 ) 

545 else: 

546 tilestack_list = precon3d.mosaic_utils.tile_region_array_data(each_czi_file) 

547 

548 for each_tilestack_scene_list in tilestack_list: 

549 for each_tilestack_channel in each_tilestack_scene_list: 

550 merged_tilestack_channel = precon3d.mosaic_utils.merge_tile_arrays( 

551 each_tilestack_channel 

552 ) 

553 precon3d.mosaic_utils.save_tile_by_idx( 

554 tilestack=merged_tilestack_channel, 

555 tile_idx=tile_idx, 

556 output_directory=output_directory, 

557 ) 

558 

559 print(f'\tTile {tile_idx} from "{each_czi_file.stem}" was saved.') 

560 

561 

562@app.callback() 

563def callback(): 

564 """CLI callback for precon3d.czi_info. 

565 

566 Provides access to metadata and tile information from CZI files. 

567 """ 

568 

569 

570if __name__ == "__main__": 

571 app()