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

1""" 

2Opera Phenix microscope implementations for ezstitcher. 

3 

4This module provides concrete implementations of FilenameParser and MetadataHandler 

5for Opera Phenix microscopes. 

6""" 

7 

8import os 

9import re 

10import logging 

11from pathlib import Path 

12from typing import Dict, List, Optional, Union, Any, Tuple 

13 

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 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21class OperaPhenixFilenameParser(FilenameParser): 

22 """Parser for Opera Phenix microscope filenames. 

23 

24 Handles Opera Phenix format filenames like: 

25 - r01c01f001p01-ch1sk1fk1fl1.tiff 

26 - r01c01f001p01-ch1.tiff 

27 """ 

28 

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) 

31 

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) 

34 

35 @classmethod 

36 def can_parse(cls, filename: str) -> bool: 

37 """ 

38 Check if this parser can parse the given filename. 

39 

40 Args: 

41 filename (str): Filename to check 

42 

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)) 

50 

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. 

55 

56 Args: 

57 filename (str): Filename to parse 

58 

59 Returns: 

60 dict or None: Dictionary with extracted components or None if parsing fails. 

61 """ 

62 basename = os.path.basename(filename) 

63 

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() 

68 

69 # Helper function to parse component strings 

70 parse_comp = lambda s: None if not s or '{' in s else int(s) 

71 

72 # Create well ID from row and column 

73 well = f"R{int(row):02d}C{int(col):02d}" 

74 

75 # Parse components 

76 site = parse_comp(site_str) 

77 channel = parse_comp(channel_str) 

78 z_index = parse_comp(z_str) 

79 

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 

89 

90 return None 

91 

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. 

97 

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) 

106 

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'") 

119 

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 

123 

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}" 

131 

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}" 

138 

139 return f"r{row:02d}c{col:02d}{site_part}{z_part}-ch{channel}sk1fk1fl1{extension}" 

140 

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. 

144 

145 Args: 

146 filename: Original filename 

147 xml_parser: Parser with XML data 

148 

149 Returns: 

150 str: New filename with remapped field ID 

151 """ 

152 if xml_parser is None: 

153 return filename 

154 

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 

159 

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) 

163 

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 ) 

175 

176 

177class OperaPhenixMetadataHandler(MetadataHandler): 

178 """ 

179 Metadata handler for Opera Phenix microscopes. 

180 

181 Handles finding and parsing Index.xml files for Opera Phenix microscopes. 

182 """ 

183 

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. 

187 

188 Args: 

189 plate_path: Path to the plate folder 

190 

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") 

195 

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. 

199 

200 Args: 

201 plate_path: Path to the plate folder 

202 

203 Returns: 

204 (grid_size_x, grid_size_y) 

205 """ 

206 index_xml = self.find_metadata_file(plate_path) 

207 

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() 

213 

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) 

219 

220 # Default grid dimensions 

221 logger.warning("Using default grid dimensions: 2x2") 

222 return 2, 2 

223 

224 def get_pixel_size(self, plate_path: Union[str, Path]) -> Optional[float]: 

225 """ 

226 Get the pixel size from Index.xml file. 

227 

228 Args: 

229 plate_path: Path to the plate folder 

230 

231 Returns: 

232 Pixel size in micrometers, or None if not available 

233 """ 

234 index_xml = self.find_metadata_file(plate_path) 

235 

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() 

241 

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) 

247 

248 # Default value 

249 logger.warning("Using default pixel size: 0.65 μm") 

250 return 0.65 # Default value in micrometers 

251 

252 def create_xml_parser(self, xml_path: Union[str, Path]): 

253 """ 

254 Create an OperaPhenixXmlParser for the given XML file. 

255 

256 Args: 

257 xml_path: Path to the XML file 

258 

259 Returns: 

260 OperaPhenixXmlParser: Parser for the XML file 

261 """ 

262 return OperaPhenixXmlParser(xml_path)