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

1""" 

2ImageXpress microscope implementations for ezstitcher. 

3 

4This module provides concrete implementations of FilenameParser and MetadataHandler 

5for ImageXpress microscopes. 

6""" 

7 

8import os 

9import re 

10import logging 

11import tifffile 

12from pathlib import Path 

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

14 

15from ezstitcher.core.microscope_interfaces import FilenameParser, MetadataHandler 

16from ezstitcher.core.file_system_manager import FileSystemManager 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21class ImageXpressFilenameParser(FilenameParser): 

22 """ 

23 Parser for ImageXpress microscope filenames. 

24 

25 Handles standard ImageXpress format filenames like: 

26 - A01_s001_w1.tif 

27 - A01_s1_w1_z1.tif 

28 """ 

29 

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+)?$') 

33 

34 @classmethod 

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

36 """ 

37 Check if this parser can parse the given filename. 

38 

39 Args: 

40 filename (str): Filename to check 

41 

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

49 

50 def parse_filename(self, filename: str) -> Optional[Dict[str, Any]]: 

51 """ 

52 Parse an ImageXpress filename to extract all components, including extension. 

53 

54 Args: 

55 filename (str): Filename to parse 

56 

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) 

62 

63 if match: 

64 well, site_str, channel_str, z_str, ext = match.groups() 

65 

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) 

71 

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 } 

80 

81 return result 

82 else: 

83 logger.debug(f"Could not parse ImageXpress filename: {filename}") 

84 return None 

85 

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. 

93 

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) 

102 

103 Returns: 

104 str: Constructed filename 

105 """ 

106 if not well: 

107 raise ValueError("Well ID cannot be empty or None.") 

108 

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

117 

118 if channel is not None: 

119 parts.append(f"_w{channel}") 

120 

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

128 

129 base_name = "".join(parts) 

130 return f"{base_name}{extension}" 

131 

132 

133class ImageXpressMetadataHandler(MetadataHandler): 

134 """ 

135 Metadata handler for ImageXpress microscopes. 

136 

137 Handles finding and parsing HTD files for ImageXpress microscopes. 

138 """ 

139 

140 def find_metadata_file(self, plate_path: Union[str, Path]) -> Optional[Path]: 

141 """ 

142 Find the HTD file for an ImageXpress plate. 

143 

144 Args: 

145 plate_path: Path to the plate folder 

146 

147 Returns: 

148 Path to the HTD file, or None if not found 

149 """ 

150 plate_path = Path(plate_path) 

151 

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] 

159 

160 return None 

161 

162 def get_grid_dimensions(self, plate_path: Union[str, Path]) -> Tuple[int, int]: 

163 """ 

164 Get grid dimensions for stitching from HTD file. 

165 

166 Args: 

167 plate_path: Path to the plate folder 

168 

169 Returns: 

170 (grid_size_x, grid_size_y) 

171 """ 

172 htd_file = self.find_metadata_file(plate_path) 

173 

174 if htd_file: 

175 # Parse HTD file 

176 try: 

177 with open(htd_file, 'r') as f: 

178 htd_content = f.read() 

179 

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) 

184 

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) 

189 

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

197 

198 # Default grid dimensions 

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

200 return 2, 2 

201 

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

203 """ 

204 Extract pixel size from TIFF metadata. 

205 

206 Looks for spatial-calibration-x in the ImageDescription tag. 

207 

208 Args: 

209 image_path: Path to a TIFF image 

210 

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) 

223 

224 if match: 

225 print(f"Found pixel size metadata {str(float(match.group(1)))} in {image_path}") 

226 return float(match.group(1)) 

227 

228 # Alternative pattern for some formats 

229 match = re.search(r'Spatial Calibration: ([0-9.]+) [uµ]m', desc) 

230 if match: 

231 

232 print(f"Found pixel size metadata {str(float(match.group(1)))} in {image_path}") 

233 return float(match.group(1)) 

234 

235 except Exception as e: 

236 print(f"Error reading metadata from {image_path}: {e}") 

237 

238 # Default value if metadata not found 

239 return 1.0 

240 

241 

242 

243 

244 """ 

245 Get the pixel size from metadata. 

246 

247 Args: 

248 plate_path: Path to the plate folder 

249 

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