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
« 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.
3This module provides CLI commands and functions to process `.czi` files,
4extract metadata, check dimensions, and save tiles.
5"""
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
15# Third-party library imports
16import numpy as np
17import pytz
18from dateutil import parser
19import typer
20from aicspylibczi import CziFile
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
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)
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.
44 Args:
45 czi_path (Path): Path to a `.czi` file or directory of `.czi` files.
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.
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
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}")
65 ref_dims = get_dims(czi_files[0])
66 print(f"{czi_files[0].stem} dimensions: {ref_dims}")
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
75 print("Dimensions are consistent")
76 return True
78 raise ValueError(f"Path is not a file or directory: {czi_path}")
81def get_dims(czi_file_path: Path) -> dict[str, int]:
82 """Return the dimension‐shape mapping for a single CZI file.
84 Args:
85 czi_file_path (Path): Path to the `.czi` file.
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()
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.
101 Args:
102 czi_file_path (Path): Path to the `.czi` file.
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
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.
124 Args:
125 czi_file_path (Path): Path to the `.czi` file.
127 Raises:
128 TypeError: If the file is not a mosaic CZI file.
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)
136 czi = CziFile(czi_file_path)
137 if not czi.is_mosaic():
138 raise TypeError("Only CZI mosaic files are supported")
140 metadata = czi.meta
141 pixel_type = czi.pixel_type
143 all_channels = metadata.findall(".//Image//Dimensions//Channels//Channel")
144 all_scenes = metadata.findall(".//Image//Dimensions//S//Scenes//Scene")
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 )
188 typer.echo(simple_czi_metadata)
189 return simple_czi_metadata
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.
195 Args:
196 all_czi_file_paths (List[Path]): List of paths to `.czi` files.
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
205 print(f'{" Reading the files: ":*^28}')
206 for each_czi_path in all_czi_file_paths:
207 print(each_czi_path.stem)
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)
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")
222 for each_scene_sp in all_scene_sp:
223 czi_fname = each_scene_sp.czi_fname
224 scene_name = each_scene_sp.scene
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)
232 avg_slice_heights.append(average_z_height)
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 )
259 row_number += 1
261 return np.array(avg_slice_heights)
264def scene_support_points(czi_file_path: Path) -> List[SceneSupportPoints]:
265 """Extract scene support points data from CZI metadata.
267 Args:
268 czi_file_path (Path): Path to the `.czi` file.
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
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
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)
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 )
300 return all_scene_sp
303def scene_support_points_global(
304 czi_file_path: Path,
305) -> List[SceneSupportPoints]:
306 """Extract global scene support points data from CZI metadata.
308 Args:
309 czi_file_path (Path): Path to the `.czi` file.
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
317 all_scene_sp = []
318 for each_scene in metadata.findall(".//SampleHolder//Template"):
319 scene_name = each_scene.attrib["Name"]
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)
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 )
340 return all_scene_sp
343def channel_metadata(czi_file_path: Path) -> List[ChannelMetadata]:
344 """Extract channel metadata from CZI files.
346 Args:
347 czi_file_path (Path): Path to the `.czi` file.
349 Returns:
350 List[ChannelMetadata]: List of channel metadata.
351 """
352 czi = CziFile(czi_file_path)
353 metadata = czi.meta
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 )
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 )
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)
393 return all_channel_metadata
396def scene_metadata(czi_file_path: Path) -> List[SceneMetadata]:
397 """Extract scene metadata from CZI files.
399 Args:
400 czi_file_path (Path): Path to the `.czi` file.
402 Returns:
403 List[SceneMetadata]: List of scene metadata.
404 """
405 czi = CziFile(czi_file_path)
406 metadata = czi.meta
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 ]
414 all_bbox = czi.get_all_mosaic_scene_bounding_boxes()
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)
434 return all_scenes_metadata
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.
445 Args:
446 czi_file_path (Path): Path to the `.czi` file.
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")
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)
459 return output_xml_path
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.
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")
476 czi_filepaths = ut.sorted_files(directory=czi_input_dir, file_extension=".czi")
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 )
484 for each_scene_index in tilestack.scenes:
485 tilestack_list = precon3d.mosaic_utils.simple_tilestack(
486 tilestack, each_scene_index, tilestack_fname
487 )
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 )
509 print(f'\tTiles from "{each_czi_file.stem}" were saved.')
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.
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")
526 czi_filepaths = ut.sorted_files(directory=czi_input_dir, file_extension=".czi")
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 )
534 for each_scene_index in tilestack.scenes:
535 tilestack_list = precon3d.mosaic_utils.simple_tilestack(
536 tilestack, each_scene_index, tilestack_fname
537 )
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)
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 )
559 print(f'\tTile {tile_idx} from "{each_czi_file.stem}" was saved.')
562@app.callback()
563def callback():
564 """CLI callback for precon3d.czi_info.
566 Provides access to metadata and tile information from CZI files.
567 """
570if __name__ == "__main__":
571 app()