Coverage for ezstitcher/microscopes/opera_phenix.py: 86%
84 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"""
2Opera Phenix microscope implementations for ezstitcher.
4This module provides concrete implementations of FilenameParser and MetadataHandler
5for Opera Phenix microscopes.
6"""
8import os
9import re
10import logging
11from pathlib import Path
12from typing import Dict, List, Optional, Union, Any, Tuple
14from ezstitcher.core.microscope_interfaces import FilenameParser, MetadataHandler
15from ezstitcher.core.opera_phenix_xml_parser import OperaPhenixXmlParser
16from ezstitcher.core.file_system_manager import FileSystemManager
18logger = logging.getLogger(__name__)
21class OperaPhenixFilenameParser(FilenameParser):
22 """Parser for Opera Phenix microscope filenames.
24 Handles Opera Phenix format filenames like:
25 - r01c01f001p01-ch1sk1fk1fl1.tiff
26 - r01c01f001p01-ch1.tiff
27 """
29 # Regular expression pattern for Opera Phenix filenames
30 _pattern = re.compile(r"r(\d{1,2})c(\d{1,2})f(\d+|\{[^\}]*\})p(\d+|\{[^\}]*\})-ch(\d+|\{[^\}]*\})(?:sk\d+)?(?:fk\d+)?(?:fl\d+)?(\.\w+)$", re.I)
32 # Pattern for extracting row and column from Opera Phenix well format
33 _well_pattern = re.compile(r"R(\d{2})C(\d{2})", re.I)
35 @classmethod
36 def can_parse(cls, filename: str) -> bool:
37 """
38 Check if this parser can parse the given filename.
40 Args:
41 filename (str): Filename to check
43 Returns:
44 bool: True if this parser can parse the filename, False otherwise
45 """
46 # Extract just the basename
47 basename = os.path.basename(filename)
48 # Check if the filename matches the Opera Phenix pattern
49 return bool(cls._pattern.match(basename))
51 def parse_filename(self, filename: str) -> Optional[Dict[str, Any]]:
52 """
53 Parse an Opera Phenix filename to extract all components.
54 Supports placeholders like {iii} which will return None for that field.
56 Args:
57 filename (str): Filename to parse
59 Returns:
60 dict or None: Dictionary with extracted components or None if parsing fails.
61 """
62 basename = os.path.basename(filename)
64 # Try parsing using the Opera Phenix pattern
65 match = self._pattern.match(basename)
66 if match:
67 row, col, site_str, z_str, channel_str, ext = match.groups()
69 # Helper function to parse component strings
70 parse_comp = lambda s: None if not s or '{' in s else int(s)
72 # Create well ID from row and column
73 well = f"R{int(row):02d}C{int(col):02d}"
75 # Parse components
76 site = parse_comp(site_str)
77 channel = parse_comp(channel_str)
78 z_index = parse_comp(z_str)
80 result = {
81 'well': well,
82 'site': site,
83 'channel': channel,
84 'wavelength': channel, # For backward compatibility
85 'z_index': z_index,
86 'extension': ext if ext else '.tif'
87 }
88 return result
90 return None
92 def construct_filename(self, well: str, site: Optional[Union[int, str]] = None, channel: Optional[int] = None,
93 z_index: Optional[Union[int, str]] = None, extension: str = '.tiff',
94 site_padding: int = 3, z_padding: int = 3) -> str:
95 """
96 Construct an Opera Phenix filename from components.
98 Args:
99 well (str): Well ID (e.g., 'R03C04' or 'A01')
100 site: Site/field number (int) or placeholder string
101 channel (int): Channel number
102 z_index: Z-index/plane (int) or placeholder string
103 extension (str, optional): File extension
104 site_padding (int, optional): Width to pad site numbers to (default: 3)
105 z_padding (int, optional): Width to pad Z-index numbers to (default: 3)
107 Returns:
108 str: Constructed filename
109 """
110 # Extract row and column from well name
111 # Check if well is in Opera Phenix format (e.g., 'R01C03')
112 match = self._well_pattern.match(well)
113 if match:
114 # Extract row and column from Opera Phenix format
115 row = int(match.group(1))
116 col = int(match.group(2))
117 else:
118 raise ValueError(f"Invalid well format: {well}. Expected format: 'R01C03'")
120 # Default Z-index to 1 if not provided
121 z_index = 1 if z_index is None else z_index
122 channel = 1 if channel is None else channel
124 # Construct filename in Opera Phenix format
125 if isinstance(site, str):
126 # If site is a string (e.g., '{iii}'), use it directly
127 site_part = f"f{site}"
128 else:
129 # Otherwise, format it as a padded integer
130 site_part = f"f{site:0{site_padding}d}"
132 if isinstance(z_index, str):
133 # If z_index is a string (e.g., '{zzz}'), use it directly
134 z_part = f"p{z_index}"
135 else:
136 # Otherwise, format it as a padded integer
137 z_part = f"p{z_index:0{z_padding}d}"
139 return f"r{row:02d}c{col:02d}{site_part}{z_part}-ch{channel}sk1fk1fl1{extension}"
141 def remap_field_in_filename(self, filename: str, xml_parser: Optional[OperaPhenixXmlParser] = None) -> str:
142 """
143 Remap the field ID in a filename to follow a top-left to bottom-right pattern.
145 Args:
146 filename: Original filename
147 xml_parser: Parser with XML data
149 Returns:
150 str: New filename with remapped field ID
151 """
152 if xml_parser is None:
153 return filename
155 # Parse the filename
156 metadata = self.parse_filename(filename)
157 if not metadata or 'site' not in metadata or metadata['site'] is None:
158 return filename
160 # Get the mapping and remap the field ID
161 mapping = xml_parser.get_field_id_mapping()
162 new_field_id = xml_parser.remap_field_id(metadata['site'], mapping)
164 # Always create a new filename with the remapped field ID and consistent padding
165 # This ensures all filenames have the same format, even if the field ID didn't change
166 return self.construct_filename(
167 well=metadata['well'],
168 site=new_field_id,
169 channel=metadata['channel'],
170 z_index=metadata['z_index'],
171 extension=metadata['extension'],
172 site_padding=3,
173 z_padding=3
174 )
177class OperaPhenixMetadataHandler(MetadataHandler):
178 """
179 Metadata handler for Opera Phenix microscopes.
181 Handles finding and parsing Index.xml files for Opera Phenix microscopes.
182 """
184 def find_metadata_file(self, plate_path: Union[str, Path]) -> Optional[Path]:
185 """
186 Find the Index.xml file for an Opera Phenix plate.
188 Args:
189 plate_path: Path to the plate folder
191 Returns:
192 Path to the Index.xml file, or None if not found
193 """
194 return FileSystemManager.find_file_recursive(plate_path, "Index.xml")
196 def get_grid_dimensions(self, plate_path: Union[str, Path]) -> Tuple[int, int]:
197 """
198 Get grid dimensions for stitching from Index.xml file.
200 Args:
201 plate_path: Path to the plate folder
203 Returns:
204 (grid_size_x, grid_size_y)
205 """
206 index_xml = self.find_metadata_file(plate_path)
208 if index_xml:
209 try:
210 # Use the OperaPhenixXmlParser to get the grid size
211 xml_parser = self.create_xml_parser(index_xml)
212 grid_size = xml_parser.get_grid_size()
214 if grid_size[0] > 0 and grid_size[1] > 0:
215 logger.info("Determined grid size from Opera Phenix Index.xml: %dx%d", grid_size[0], grid_size[1])
216 return grid_size
217 except Exception as e:
218 logger.error("Error parsing Opera Phenix Index.xml: %s", e)
220 # Default grid dimensions
221 logger.warning("Using default grid dimensions: 2x2")
222 return 2, 2
224 def get_pixel_size(self, plate_path: Union[str, Path]) -> Optional[float]:
225 """
226 Get the pixel size from Index.xml file.
228 Args:
229 plate_path: Path to the plate folder
231 Returns:
232 Pixel size in micrometers, or None if not available
233 """
234 index_xml = self.find_metadata_file(plate_path)
236 if index_xml:
237 try:
238 # Use the OperaPhenixXmlParser to get the pixel size
239 xml_parser = self.create_xml_parser(index_xml)
240 pixel_size = xml_parser.get_pixel_size()
242 if pixel_size > 0:
243 logger.info("Determined pixel size from Opera Phenix Index.xml: %.4f μm", pixel_size)
244 return pixel_size
245 except Exception as e:
246 logger.error("Error getting pixel size from Opera Phenix Index.xml: %s", e)
248 # Default value
249 logger.warning("Using default pixel size: 0.65 μm")
250 return 0.65 # Default value in micrometers
252 def create_xml_parser(self, xml_path: Union[str, Path]):
253 """
254 Create an OperaPhenixXmlParser for the given XML file.
256 Args:
257 xml_path: Path to the XML file
259 Returns:
260 OperaPhenixXmlParser: Parser for the XML file
261 """
262 return OperaPhenixXmlParser(xml_path)