Coverage for ezstitcher/microscopes/imagexpress.py: 80%
93 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-04-30 13:20 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2025-04-30 13:20 +0000
1"""
2ImageXpress microscope implementations for ezstitcher.
4This module provides concrete implementations of FilenameParser and MetadataHandler
5for ImageXpress microscopes.
6"""
8import os
9import re
10import logging
11import tifffile
12from pathlib import Path
13from typing import Dict, List, Optional, Union, Any, Tuple
15from ezstitcher.core.microscope_interfaces import FilenameParser, MetadataHandler
16from ezstitcher.core.file_system_manager import FileSystemManager
18logger = logging.getLogger(__name__)
21class ImageXpressFilenameParser(FilenameParser):
22 """
23 Parser for ImageXpress microscope filenames.
25 Handles standard ImageXpress format filenames like:
26 - A01_s001_w1.tif
27 - A01_s1_w1_z1.tif
28 """
30 # Regular expression pattern for ImageXpress filenames
31 #_pattern = re.compile(r'(?:.*?_)?([A-Z]\d+)(?:_s(\d+))?(?:_w(\d+))?(?:_z(\d+))?(\.\w+)?$')
32 _pattern = re.compile(r'(?:.*?_)?([A-Z]\d+)(?:_s(\d+|\{[^\}]*\}))?(?:_w(\d+|\{[^\}]*\}))?(?:_z(\d+|\{[^\}]*\}))?(\.\w+)?$')
34 @classmethod
35 def can_parse(cls, filename: str) -> bool:
36 """
37 Check if this parser can parse the given filename.
39 Args:
40 filename (str): Filename to check
42 Returns:
43 bool: True if this parser can parse the filename, False otherwise
44 """
45 # Extract just the basename
46 basename = os.path.basename(filename)
47 # Check if the filename matches the ImageXpress pattern
48 return bool(cls._pattern.match(basename))
50 def parse_filename(self, filename: str) -> Optional[Dict[str, Any]]:
51 """
52 Parse an ImageXpress filename to extract all components, including extension.
54 Args:
55 filename (str): Filename to parse
57 Returns:
58 dict or None: Dictionary with extracted components or None if parsing fails
59 """
60 basename = os.path.basename(filename)
61 match = self._pattern.match(basename)
63 if match:
64 well, site_str, channel_str, z_str, ext = match.groups()
66 #handle {} place holders
67 parse_comp = lambda s: None if not s or '{' in s else int(s)
68 site = parse_comp(site_str)
69 channel = parse_comp(channel_str)
70 z_index = parse_comp(z_str)
72 # Use the parsed components in the result
73 result = {
74 'well': well,
75 'site': site,
76 'channel': channel,
77 'z_index': z_index,
78 'extension': ext if ext else '.tif' # Default if somehow empty
79 }
81 return result
82 else:
83 logger.debug(f"Could not parse ImageXpress filename: {filename}")
84 return None
86 def construct_filename(self, well: str, site: Optional[Union[int, str]] = None,
87 channel: Optional[int] = None,
88 z_index: Optional[Union[int, str]] = None,
89 extension: str = '.tif',
90 site_padding: int = 3, z_padding: int = 3) -> str:
91 """
92 Construct an ImageXpress filename from components, only including parts if provided.
94 Args:
95 well (str): Well ID (e.g., 'A01')
96 site (int or str, optional): Site number or placeholder string (e.g., '{iii}')
97 channel (int, optional): Channel number
98 z_index (int or str, optional): Z-index or placeholder string (e.g., '{zzz}')
99 extension (str, optional): File extension
100 site_padding (int, optional): Width to pad site numbers to (default: 3)
101 z_padding (int, optional): Width to pad Z-index numbers to (default: 3)
103 Returns:
104 str: Constructed filename
105 """
106 if not well:
107 raise ValueError("Well ID cannot be empty or None.")
109 parts = [well]
110 if site is not None:
111 if isinstance(site, str):
112 # If site is a string (e.g., '{iii}'), use it directly
113 parts.append(f"_s{site}")
114 else:
115 # Otherwise, format it as a padded integer
116 parts.append(f"_s{site:0{site_padding}d}")
118 if channel is not None:
119 parts.append(f"_w{channel}")
121 if z_index is not None:
122 if isinstance(z_index, str):
123 # If z_index is a string (e.g., '{zzz}'), use it directly
124 parts.append(f"_z{z_index}")
125 else:
126 # Otherwise, format it as a padded integer
127 parts.append(f"_z{z_index:0{z_padding}d}")
129 base_name = "".join(parts)
130 return f"{base_name}{extension}"
133class ImageXpressMetadataHandler(MetadataHandler):
134 """
135 Metadata handler for ImageXpress microscopes.
137 Handles finding and parsing HTD files for ImageXpress microscopes.
138 """
140 def find_metadata_file(self, plate_path: Union[str, Path]) -> Optional[Path]:
141 """
142 Find the HTD file for an ImageXpress plate.
144 Args:
145 plate_path: Path to the plate folder
147 Returns:
148 Path to the HTD file, or None if not found
149 """
150 plate_path = Path(plate_path)
152 # Look for ImageXpress HTD file in plate directory
153 htd_files = list(plate_path.glob("*.HTD"))
154 if htd_files:
155 for htd_file in htd_files:
156 if 'plate' in htd_file.name.lower():
157 return htd_file
158 return htd_files[0]
160 return None
162 def get_grid_dimensions(self, plate_path: Union[str, Path]) -> Tuple[int, int]:
163 """
164 Get grid dimensions for stitching from HTD file.
166 Args:
167 plate_path: Path to the plate folder
169 Returns:
170 (grid_size_x, grid_size_y)
171 """
172 htd_file = self.find_metadata_file(plate_path)
174 if htd_file:
175 # Parse HTD file
176 try:
177 with open(htd_file, 'r') as f:
178 htd_content = f.read()
180 # Extract grid dimensions - try multiple formats
181 # First try the new format with "XSites" and "YSites"
182 cols_match = re.search(r'"XSites", (\d+)', htd_content)
183 rows_match = re.search(r'"YSites", (\d+)', htd_content)
185 # If not found, try the old format with SiteColumns and SiteRows
186 if not (cols_match and rows_match):
187 cols_match = re.search(r'SiteColumns=(\d+)', htd_content)
188 rows_match = re.search(r'SiteRows=(\d+)', htd_content)
190 if cols_match and rows_match:
191 grid_size_x = int(cols_match.group(1))
192 grid_size_y = int(rows_match.group(1))
193 logger.info(f"Using grid dimensions from HTD file: {grid_size_x}x{grid_size_y}")
194 return grid_size_x, grid_size_y
195 except Exception as e:
196 logger.error(f"Error parsing HTD file {htd_file}: {e}")
198 # Default grid dimensions
199 logger.warning("Using default grid dimensions: 2x2")
200 return 2, 2
202 def get_pixel_size(self, plate_path: Union[str, Path]) -> Optional[float]:
203 """
204 Extract pixel size from TIFF metadata.
206 Looks for spatial-calibration-x in the ImageDescription tag.
208 Args:
209 image_path: Path to a TIFF image
211 Returns:
212 float: Pixel size in microns (default 1.0 if not found)
213 """
214 for image_path in FileSystemManager.list_image_files(plate_path):
215 try:
216 # Read TIFF tags
217 with tifffile.TiffFile(image_path) as tif:
218 # Try to get ImageDescription tag
219 if tif.pages[0].tags.get('ImageDescription'):
220 desc = tif.pages[0].tags['ImageDescription'].value
221 # Look for spatial calibration using regex
222 match = re.search(r'id="spatial-calibration-x"[^>]*value="([0-9.]+)"', desc)
224 if match:
225 print(f"Found pixel size metadata {str(float(match.group(1)))} in {image_path}")
226 return float(match.group(1))
228 # Alternative pattern for some formats
229 match = re.search(r'Spatial Calibration: ([0-9.]+) [uµ]m', desc)
230 if match:
232 print(f"Found pixel size metadata {str(float(match.group(1)))} in {image_path}")
233 return float(match.group(1))
235 except Exception as e:
236 print(f"Error reading metadata from {image_path}: {e}")
238 # Default value if metadata not found
239 return 1.0
244 """
245 Get the pixel size from metadata.
247 Args:
248 plate_path: Path to the plate folder
250 Returns:
251 Pixel size in micrometers, or None if not available
252 """
253 # ImageXpress HTD files typically don't contain pixel size information
254 # We would need to extract it from the image metadata
255 return 0.65 # Default value in micrometers