Coverage for openhcs/microscopes/imagexpress.py: 58.3%

298 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-01 18:33 +0000

1""" 

2ImageXpress microscope implementations for openhcs. 

3 

4This module provides concrete implementations of FilenameParser and MetadataHandler 

5for ImageXpress microscopes. 

6""" 

7 

8import logging 

9import os 

10import re 

11from pathlib import Path 

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

13 

14import tifffile 

15 

16from openhcs.constants.constants import Backend 

17from openhcs.io.exceptions import MetadataNotFoundError 

18from openhcs.io.filemanager import FileManager 

19from openhcs.microscopes.microscope_base import MicroscopeHandler 

20from openhcs.microscopes.microscope_interfaces import (FilenameParser, 

21 MetadataHandler) 

22 

23logger = logging.getLogger(__name__) 

24 

25class ImageXpressHandler(MicroscopeHandler): 

26 """ 

27 MicroscopeHandler implementation for Molecular Devices ImageXpress systems. 

28 

29 This handler binds the ImageXpress filename parser and metadata handler, 

30 enforcing semantic alignment between file layout parsing and metadata resolution. 

31 """ 

32 

33 # Explicit microscope type for proper registration 

34 _microscope_type = 'imagexpress' 

35 

36 # Class attribute for automatic metadata handler registration (set after class definition) 

37 _metadata_handler_class = None 

38 

39 def __init__(self, filemanager: FileManager, pattern_format: Optional[str] = None): 

40 # Initialize parser with filemanager, respecting its interface 

41 self.parser = ImageXpressFilenameParser(filemanager, pattern_format) 

42 self.metadata_handler = ImageXpressMetadataHandler(filemanager) 

43 super().__init__(parser=self.parser, metadata_handler=self.metadata_handler) 

44 

45 @property 

46 def common_dirs(self) -> List[str]: 

47 """Subdirectory names commonly used by ImageXpress""" 

48 return ['TimePoint_1'] 

49 

50 @property 

51 def microscope_type(self) -> str: 

52 """Microscope type identifier (for interface enforcement only).""" 

53 return 'imagexpress' 

54 

55 @property 

56 def metadata_handler_class(self) -> Type[MetadataHandler]: 

57 """Metadata handler class (for interface enforcement only).""" 

58 return ImageXpressMetadataHandler 

59 

60 @property 

61 def compatible_backends(self) -> List[Backend]: 

62 """ 

63 ImageXpress is compatible with DISK backend only. 

64 

65 Legacy microscope format with standard file operations. 

66 """ 

67 return [Backend.DISK] 

68 

69 

70 

71 # Uses default workspace initialization from base class 

72 

73 def _prepare_workspace(self, workspace_path: Path, filemanager: FileManager) -> Path: 

74 """ 

75 Flattens the Z-step folder structure and renames image files for 

76 consistent padding and Z-plane resolution. 

77 

78 This method performs preparation but does not determine the final image directory. 

79 

80 Args: 

81 workspace_path: Path to the symlinked workspace 

82 filemanager: FileManager instance for file operations 

83 

84 Returns: 

85 Path to the flattened image directory. 

86 """ 

87 # Find all subdirectories in workspace using the filemanager 

88 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

89 entries = filemanager.list_dir(workspace_path, Backend.DISK.value) 

90 

91 # Filter entries to get only directories 

92 subdirs = [] 

93 for entry in entries: 

94 entry_path = Path(workspace_path) / entry 

95 if entry_path.is_dir(): 

96 subdirs.append(entry_path) 

97 

98 # Check if any subdirectory contains common_dirs string 

99 common_dir_found = False 

100 

101 for subdir in subdirs: 

102 if any(common_dir in subdir.name for common_dir in self.common_dirs): 102 ↛ 101line 102 didn't jump to line 101 because the condition on line 102 was always true

103 self._flatten_zsteps(subdir, filemanager) 

104 common_dir_found = True 

105 

106 # If no common directory found, process the workspace directly 

107 if not common_dir_found: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 self._flatten_zsteps(workspace_path, filemanager) 

109 

110 # Remove thumbnail symlinks after processing 

111 # Find all files in workspace recursively 

112 _, all_files = filemanager.collect_dirs_and_files(workspace_path, Backend.DISK.value, recursive=True) 

113 

114 for file_path in all_files: 

115 # Check if filename contains "thumb" and if it's a symlink 

116 if "thumb" in Path(file_path).name.lower() and filemanager.is_symlink(file_path, Backend.DISK.value): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 try: 

118 filemanager.delete(file_path, Backend.DISK.value) 

119 logger.debug("Removed thumbnail symlink: %s", file_path) 

120 except Exception as e: 

121 logger.warning("Failed to remove thumbnail symlink %s: %s", file_path, e) 

122 

123 # Return the image directory 

124 return workspace_path 

125 

126 def _flatten_zsteps(self, directory: Path, fm: FileManager): 

127 """ 

128 Process Z-step folders in the given directory. 

129 

130 Args: 

131 directory: Path to directory that might contain Z-step folders 

132 fm: FileManager instance for file operations 

133 """ 

134 # Check for Z step folders 

135 zstep_pattern = re.compile(r"ZStep[_-]?(\d+)", re.IGNORECASE) 

136 

137 # List all subdirectories using the filemanager 

138 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

139 entries = fm.list_dir(directory, Backend.DISK.value) 

140 

141 # Filter entries to get only directories 

142 subdirs = [] 

143 for entry in entries: 

144 entry_path = Path(directory) / entry 

145 if entry_path.is_dir(): 

146 subdirs.append(entry_path) 

147 

148 # Find potential Z-step folders 

149 potential_z_folders = [] 

150 for d in subdirs: 

151 dir_name = d.name if isinstance(d, Path) else os.path.basename(str(d)) 

152 if zstep_pattern.search(dir_name): 152 ↛ 150line 152 didn't jump to line 150 because the condition on line 152 was always true

153 potential_z_folders.append(d) 

154 

155 if not potential_z_folders: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true

156 logger.info("No Z step folders found in %s. Processing files directly in directory.", directory) 

157 # Process files directly in the directory to ensure complete metadata 

158 self._process_files_in_directory(directory, fm) 

159 return 

160 

161 # Sort Z folders by index 

162 z_folders = [] 

163 for d in potential_z_folders: 

164 dir_name = d.name if isinstance(d, Path) else os.path.basename(str(d)) 

165 match = zstep_pattern.search(dir_name) 

166 if match: 166 ↛ 163line 166 didn't jump to line 163 because the condition on line 166 was always true

167 z_index = int(match.group(1)) 

168 z_folders.append((z_index, d)) 

169 

170 # Sort by Z index 

171 z_folders.sort(key=lambda x: x[0]) 

172 

173 # Process each Z folder 

174 for z_index, z_dir in z_folders: 

175 # List all files in the Z folder 

176 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

177 img_files = fm.list_files(z_dir, Backend.DISK.value) 

178 

179 for img_file in img_files: 

180 # Skip if not a file 

181 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

182 if not fm.is_file(img_file, Backend.DISK.value): 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 continue 

184 

185 # Get the filename 

186 img_file_name = img_file.name if isinstance(img_file, Path) else os.path.basename(str(img_file)) 

187 

188 # Parse the original filename to extract components 

189 components = self.parser.parse_filename(img_file_name) 

190 

191 if not components: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true

192 continue 

193 

194 # Update the z_index in the components 

195 components['z_index'] = z_index 

196 

197 # Use the parser to construct a new filename with the updated z_index 

198 new_name = self.parser.construct_filename(**components) 

199 

200 # Create the new path in the parent directory 

201 new_path = directory / new_name if isinstance(directory, Path) else Path(os.path.join(str(directory), new_name)) 

202 

203 try: 

204 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

205 # Use replace_symlinks=True to allow overwriting existing symlinks 

206 fm.move(img_file, new_path, Backend.DISK.value, replace_symlinks=True) 

207 logger.debug("Moved %s to %s", img_file, new_path) 

208 except FileExistsError as e: 

209 # Propagate FileExistsError with clear message 

210 logger.error("Cannot move %s to %s: %s", img_file, new_path, e) 

211 raise 

212 except Exception as e: 

213 logger.error("Error moving %s to %s: %s", img_file, new_path, e) 

214 raise 

215 

216 # Remove Z folders after all files have been moved 

217 for _, z_dir in z_folders: 

218 try: 

219 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

220 fm.delete_all(z_dir, Backend.DISK.value) 

221 logger.debug("Removed Z-step folder: %s", z_dir) 

222 except Exception as e: 

223 logger.warning("Failed to remove Z-step folder %s: %s", z_dir, e) 

224 

225 def _process_files_in_directory(self, directory: Path, fm: FileManager): 

226 """ 

227 Process files directly in a directory to ensure complete metadata. 

228 

229 This handles files that are not in Z-step folders but may be missing 

230 channel or z-index information. Similar to how Z-step processing adds 

231 z_index, this adds default values for missing components. 

232 

233 Args: 

234 directory: Path to directory containing image files 

235 fm: FileManager instance for file operations 

236 """ 

237 # List all image files in the directory 

238 img_files = fm.list_files(directory, Backend.DISK.value) 

239 

240 for img_file in img_files: 

241 # Skip if not a file 

242 if not fm.is_file(img_file, Backend.DISK.value): 

243 continue 

244 

245 # Get the filename 

246 img_file_name = img_file.name if isinstance(img_file, Path) else os.path.basename(str(img_file)) 

247 

248 # Parse the original filename to extract components 

249 components = self.parser.parse_filename(img_file_name) 

250 

251 if not components: 

252 continue 

253 

254 # Check if we need to add missing metadata 

255 needs_rebuild = False 

256 

257 # Add default channel if missing (like we do for z_index in Z-step processing) 

258 if components['channel'] is None: 

259 components['channel'] = 1 

260 needs_rebuild = True 

261 logger.debug("Added default channel=1 to file without channel info: %s", img_file_name) 

262 

263 # Add default z_index if missing (for 2D images) 

264 if components['z_index'] is None: 

265 components['z_index'] = 1 

266 needs_rebuild = True 

267 logger.debug("Added default z_index=1 to file without z_index info: %s", img_file_name) 

268 

269 # Only rebuild filename if we added missing components 

270 if needs_rebuild: 

271 # Construct new filename with complete metadata 

272 new_name = self.parser.construct_filename(**components) 

273 

274 # Only rename if the filename actually changed 

275 if new_name != img_file_name: 

276 new_path = directory / new_name 

277 

278 try: 

279 # Pass the backend parameter as required by Clause 306 

280 # Use replace_symlinks=True to allow overwriting existing symlinks 

281 fm.move(img_file, new_path, Backend.DISK.value, replace_symlinks=True) 

282 logger.debug("Rebuilt filename with complete metadata: %s -> %s", img_file_name, new_name) 

283 except FileExistsError as e: 

284 logger.error("Cannot rename %s to %s: %s", img_file, new_path, e) 

285 raise 

286 except Exception as e: 

287 logger.error("Error renaming %s to %s: %s", img_file, new_path, e) 

288 raise 

289 

290 

291class ImageXpressFilenameParser(FilenameParser): 

292 """ 

293 Parser for ImageXpress microscope filenames. 

294 

295 Handles standard ImageXpress format filenames like: 

296 - A01_s001_w1.tif 

297 - A01_s1_w1_z1.tif 

298 """ 

299 

300 # Regular expression pattern for ImageXpress filenames 

301 _pattern = re.compile(r'(?:.*?_)?([A-Z]\d+)(?:_s(\d+|\{[^\}]*\}))?(?:_w(\d+|\{[^\}]*\}))?(?:_z(\d+|\{[^\}]*\}))?(\.\w+)?$') 

302 

303 def __init__(self, filemanager=None, pattern_format=None): 

304 """ 

305 Initialize the parser. 

306 

307 Args: 

308 filemanager: FileManager instance (not used, but required for interface compatibility) 

309 pattern_format: Optional pattern format (not used, but required for interface compatibility) 

310 """ 

311 super().__init__() # Initialize the generic parser interface 

312 

313 # These parameters are not used by this parser, but are required for interface compatibility 

314 self.filemanager = filemanager 

315 self.pattern_format = pattern_format 

316 

317 @classmethod 

318 def can_parse(cls, filename: Union[str, Any]) -> bool: 

319 """ 

320 Check if this parser can parse the given filename. 

321 

322 Args: 

323 filename: Filename to check (str or VirtualPath) 

324 

325 Returns: 

326 bool: True if this parser can parse the filename, False otherwise 

327 """ 

328 # For strings and other objects, convert to string and get basename 

329 # 🔒 Clause 17 — VFS Boundary Method 

330 # Use Path.name instead of os.path.basename for string operations 

331 basename = Path(str(filename)).name 

332 

333 # Check if the filename matches the ImageXpress pattern 

334 return bool(cls._pattern.match(basename)) 

335 

336 # 🔒 Clause 17 — VFS Boundary Method 

337 # This is a string operation that doesn't perform actual file I/O 

338 # but is needed for filename parsing during runtime. 

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

340 """ 

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

342 

343 Args: 

344 filename: Filename to parse (str or VirtualPath) 

345 

346 Returns: 

347 dict or None: Dictionary with extracted components or None if parsing fails 

348 """ 

349 

350 basename = Path(str(filename)).name 

351 

352 match = self._pattern.match(basename) 

353 

354 if match: 354 ↛ 374line 354 didn't jump to line 374 because the condition on line 354 was always true

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

356 

357 #handle {} place holders 

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

359 site = parse_comp(site_str) 

360 channel = parse_comp(channel_str) 

361 z_index = parse_comp(z_str) 

362 

363 # Use the parsed components in the result 

364 result = { 

365 'well': well, 

366 'site': site, 

367 'channel': channel, 

368 'z_index': z_index, 

369 'extension': ext if ext else '.tif' # Default if somehow empty 

370 } 

371 

372 return result 

373 else: 

374 logger.debug("Could not parse ImageXpress filename: %s", filename) 

375 return None 

376 

377 def extract_component_coordinates(self, component_value: str) -> Tuple[str, str]: 

378 """ 

379 Extract coordinates from component identifier (typically well). 

380 

381 Args: 

382 component_value (str): Component identifier (e.g., 'A01', 'C04') 

383 

384 Returns: 

385 Tuple[str, str]: (row, column) where row is like 'A', 'C' and column is like '01', '04' 

386 

387 Raises: 

388 ValueError: If component format is invalid 

389 """ 

390 if not component_value or len(component_value) < 2: 

391 raise ValueError(f"Invalid component format: {component_value}") 

392 

393 # ImageXpress format: A01, B02, C04, etc. 

394 row = component_value[0] 

395 col = component_value[1:] 

396 

397 if not row.isalpha() or not col.isdigit(): 

398 raise ValueError(f"Invalid ImageXpress component format: {component_value}. Expected format like 'A01', 'C04'") 

399 

400 return row, col 

401 

402 def construct_filename(self, extension: str = '.tif', site_padding: int = 3, z_padding: int = 3, **component_values) -> str: 

403 """ 

404 Construct an ImageXpress filename from components, only including parts if provided. 

405 

406 This method now uses **kwargs to accept any component values dynamically, 

407 making it compatible with the generic parser interface. 

408 

409 Args: 

410 extension (str, optional): File extension (default: '.tif') 

411 site_padding (int, optional): Width to pad site numbers to (default: 3) 

412 z_padding (int, optional): Width to pad Z-index numbers to (default: 3) 

413 **component_values: Component values as keyword arguments. 

414 Expected keys: well, site, channel, z_index 

415 

416 Returns: 

417 str: Constructed filename 

418 """ 

419 # Extract components from kwargs 

420 well = component_values.get('well') 

421 site = component_values.get('site') 

422 channel = component_values.get('channel') 

423 z_index = component_values.get('z_index') 

424 if not well: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

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

426 

427 parts = [well] 

428 if site is not None: 428 ↛ 436line 428 didn't jump to line 436 because the condition on line 428 was always true

429 if isinstance(site, str): 429 ↛ 431line 429 didn't jump to line 431 because the condition on line 429 was never true

430 # If site is a string (e.g., '{iii}'), use it directly 

431 parts.append(f"_s{site}") 

432 else: 

433 # Otherwise, format it as a padded integer 

434 parts.append(f"_s{site:0{site_padding}d}") 

435 

436 if channel is not None: 436 ↛ 439line 436 didn't jump to line 439 because the condition on line 436 was always true

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

438 

439 if z_index is not None: 439 ↛ 447line 439 didn't jump to line 447 because the condition on line 439 was always true

440 if isinstance(z_index, str): 440 ↛ 442line 440 didn't jump to line 442 because the condition on line 440 was never true

441 # If z_index is a string (e.g., '{zzz}'), use it directly 

442 parts.append(f"_z{z_index}") 

443 else: 

444 # Otherwise, format it as a padded integer 

445 parts.append(f"_z{z_index:0{z_padding}d}") 

446 

447 base_name = "".join(parts) 

448 return f"{base_name}{extension}" 

449 

450 

451class ImageXpressMetadataHandler(MetadataHandler): 

452 """ 

453 Metadata handler for ImageXpress microscopes. 

454 

455 Handles finding and parsing HTD files for ImageXpress microscopes. 

456 Inherits fallback values from MetadataHandler ABC. 

457 """ 

458 def __init__(self, filemanager: FileManager): 

459 """ 

460 Initialize the metadata handler. 

461 

462 Args: 

463 filemanager: FileManager instance for file operations. 

464 """ 

465 super().__init__() # Call parent's __init__ without parameters 

466 self.filemanager = filemanager # Store filemanager as an instance attribute 

467 

468 def find_metadata_file(self, plate_path: Union[str, Path], 

469 context: Optional['ProcessingContext'] = None) -> Path: 

470 """ 

471 Find the HTD file for an ImageXpress plate. 

472 

473 Args: 

474 plate_path: Path to the plate folder 

475 context: Optional ProcessingContext (not used) 

476 

477 Returns: 

478 Path to the HTD file 

479 

480 Raises: 

481 MetadataNotFoundError: If no HTD file is found 

482 TypeError: If plate_path is not a valid path type 

483 """ 

484 # Ensure plate_path is a Path object 

485 if isinstance(plate_path, str): 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 plate_path = Path(plate_path) 

487 elif not isinstance(plate_path, Path): 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true

488 raise TypeError(f"Expected str or Path, got {type(plate_path).__name__}") 

489 

490 # Ensure the path exists 

491 if not plate_path.exists(): 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 raise FileNotFoundError(f"Plate path does not exist: {plate_path}") 

493 

494 # Use filemanager to list files 

495 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

496 htd_files = self.filemanager.list_files(plate_path, Backend.DISK.value, pattern="*.HTD") 

497 if htd_files: 

498 for htd_file in htd_files: 498 ↛ 507line 498 didn't jump to line 507 because the loop on line 498 didn't complete

499 # Convert to Path if it's a string 

500 if isinstance(htd_file, str): 500 ↛ 503line 500 didn't jump to line 503 because the condition on line 500 was always true

501 htd_file = Path(htd_file) 

502 

503 if 'plate' in htd_file.name.lower(): 503 ↛ 498line 503 didn't jump to line 498 because the condition on line 503 was always true

504 return htd_file 

505 

506 # Return the first file 

507 first_file = htd_files[0] 

508 if isinstance(first_file, str): 

509 return Path(first_file) 

510 return first_file 

511 

512 # 🔒 Clause 65 — No Fallback Logic 

513 # Fail loudly if no HTD file is found 

514 raise MetadataNotFoundError("No HTD or metadata file found. ImageXpressHandler requires declared metadata.") 

515 

516 def get_grid_dimensions(self, plate_path: Union[str, Path], 

517 context: Optional['ProcessingContext'] = None) -> Tuple[int, int]: 

518 """ 

519 Get grid dimensions for stitching from HTD file. 

520 

521 Args: 

522 plate_path: Path to the plate folder 

523 context: Optional ProcessingContext (not used) 

524 

525 Returns: 

526 (grid_rows, grid_cols) - UPDATED: Now returns (rows, cols) for MIST compatibility 

527 

528 Raises: 

529 MetadataNotFoundError: If no HTD file is found 

530 ValueError: If grid dimensions cannot be determined from metadata 

531 """ 

532 htd_file = self.find_metadata_file(plate_path, context) 

533 

534 # Parse HTD file 

535 try: 

536 # HTD files are plain text, but may use different encodings 

537 # Try multiple encodings in order of likelihood 

538 encodings_to_try = ['utf-8', 'windows-1252', 'latin-1', 'cp1252', 'iso-8859-1'] 

539 htd_content = None 

540 

541 for encoding in encodings_to_try: 541 ↛ 551line 541 didn't jump to line 551 because the loop on line 541 didn't complete

542 try: 

543 with open(htd_file, 'r', encoding=encoding) as f: 

544 htd_content = f.read() 

545 logger.debug("Successfully read HTD file with encoding: %s", encoding) 

546 break 

547 except UnicodeDecodeError: 

548 logger.debug("Failed to read HTD file with encoding: %s", encoding) 

549 continue 

550 

551 if htd_content is None: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 raise ValueError(f"Could not read HTD file with any supported encoding: {encodings_to_try}") 

553 

554 # Extract grid dimensions - try multiple formats 

555 # First try the new format with "XSites" and "YSites" 

556 cols_match = re.search(r'"XSites", (\d+)', htd_content) 

557 rows_match = re.search(r'"YSites", (\d+)', htd_content) 

558 

559 # If not found, try the old format with SiteColumns and SiteRows 

560 if not (cols_match and rows_match): 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true

561 cols_match = re.search(r'SiteColumns=(\d+)', htd_content) 

562 rows_match = re.search(r'SiteRows=(\d+)', htd_content) 

563 

564 if cols_match and rows_match: 564 ↛ 573line 564 didn't jump to line 573 because the condition on line 564 was always true

565 grid_size_x = int(cols_match.group(1)) # cols from metadata 

566 grid_size_y = int(rows_match.group(1)) # rows from metadata 

567 logger.info("Using grid dimensions from HTD file: %dx%d (cols x rows)", grid_size_x, grid_size_y) 

568 # FIXED: Return (rows, cols) for MIST compatibility instead of (cols, rows) 

569 return grid_size_y, grid_size_x 

570 

571 # 🔒 Clause 65 — No Fallback Logic 

572 # Fail loudly if grid dimensions cannot be determined 

573 raise ValueError(f"Could not find grid dimensions in HTD file {htd_file}") 

574 except Exception as e: 

575 # 🔒 Clause 65 — No Fallback Logic 

576 # Fail loudly on any error 

577 raise ValueError(f"Error parsing HTD file {htd_file}: {e}") 

578 

579 def get_pixel_size(self, plate_path: Union[str, Path], 

580 context: Optional['ProcessingContext'] = None) -> float: 

581 """ 

582 Gets pixel size by reading TIFF tags from an image file via FileManager. 

583 

584 Args: 

585 plate_path: Path to the plate folder 

586 context: Optional ProcessingContext (not used) 

587 

588 Returns: 

589 Pixel size in micrometers 

590 

591 Raises: 

592 ValueError: If pixel size cannot be determined from metadata 

593 """ 

594 # This implementation requires: 

595 # 1. The backend used by filemanager supports listing image files. 

596 # 2. The backend allows direct reading of TIFF file tags. 

597 # 3. Images are in TIFF format. 

598 try: 

599 # Use filemanager to list potential image files 

600 # Pass the backend parameter as required by Clause 306 (Backend Positional Parameters) 

601 image_files = self.filemanager.list_image_files(plate_path, Backend.DISK.value, extensions={'.tif', '.tiff'}, recursive=True) 

602 if not image_files: 

603 # 🔒 Clause 65 — No Fallback Logic 

604 # Fail loudly if no image files are found 

605 raise ValueError(f"No TIFF images found in {plate_path} to read pixel size") 

606 

607 # Attempt to read tags from the first found image 

608 first_image_path = image_files[0] 

609 

610 # Convert to Path if it's a string 

611 if isinstance(first_image_path, str): 

612 first_image_path = Path(first_image_path) 

613 elif not isinstance(first_image_path, Path): 

614 raise TypeError(f"Expected str or Path, got {type(first_image_path).__name__}") 

615 

616 # Use the path with tifffile 

617 with tifffile.TiffFile(first_image_path) as tif: 

618 # Try to get ImageDescription tag 

619 if tif.pages[0].tags.get('ImageDescription'): 

620 desc = tif.pages[0].tags['ImageDescription'].value 

621 # Look for spatial calibration using regex 

622 match = re.search(r'id="spatial-calibration-x"[^>]*value="([0-9.]+)"', desc) 

623 if match: 

624 logger.info("Found pixel size metadata %.3f in %s", 

625 float(match.group(1)), first_image_path) 

626 return float(match.group(1)) 

627 

628 # Alternative pattern for some formats 

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

630 if match: 

631 logger.info("Found pixel size metadata %.3f in %s", 

632 float(match.group(1)), first_image_path) 

633 return float(match.group(1)) 

634 

635 # 🔒 Clause 65 — No Fallback Logic 

636 # Fail loudly if pixel size cannot be determined 

637 raise ValueError(f"Could not find pixel size in image metadata for {plate_path}") 

638 

639 except Exception as e: 

640 # 🔒 Clause 65 — No Fallback Logic 

641 # Fail loudly on any error 

642 raise ValueError(f"Error getting pixel size from {plate_path}: {e}") 

643 

644 def get_channel_values(self, plate_path: Union[str, Path]) -> Optional[Dict[str, Optional[str]]]: 

645 """ 

646 Get channel key->name mapping from ImageXpress HTD file. 

647 

648 Args: 

649 plate_path: Path to the plate folder (str or Path) 

650 

651 Returns: 

652 Dict mapping channel IDs to channel names from metadata 

653 Example: {"1": "TL-20", "2": "DAPI", "3": "FITC", "4": "CY5"} 

654 """ 

655 try: 

656 # Find and parse HTD file 

657 htd_file = self.find_metadata_file(plate_path) 

658 

659 # Read HTD file content 

660 encodings_to_try = ['utf-8', 'windows-1252', 'latin-1', 'cp1252', 'iso-8859-1'] 

661 htd_content = None 

662 

663 for encoding in encodings_to_try: 663 ↛ 671line 663 didn't jump to line 671 because the loop on line 663 didn't complete

664 try: 

665 with open(htd_file, 'r', encoding=encoding) as f: 

666 htd_content = f.read() 

667 break 

668 except UnicodeDecodeError: 

669 continue 

670 

671 if htd_content is None: 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 logger.debug("Could not read HTD file with any supported encoding") 

673 return None 

674 

675 # Extract channel information from WaveName entries 

676 channel_mapping = {} 

677 

678 # ImageXpress stores channel names as WaveName1, WaveName2, etc. 

679 wave_pattern = re.compile(r'"WaveName(\d+)", "([^"]*)"') 

680 matches = wave_pattern.findall(htd_content) 

681 

682 for wave_num, wave_name in matches: 

683 if wave_name: # Only add non-empty wave names 683 ↛ 682line 683 didn't jump to line 682 because the condition on line 683 was always true

684 channel_mapping[wave_num] = wave_name 

685 

686 return channel_mapping if channel_mapping else None 

687 

688 except Exception as e: 

689 logger.debug(f"Could not extract channel names from ImageXpress metadata: {e}") 

690 return None 

691 

692 def get_well_values(self, plate_path: Union[str, Path]) -> Optional[Dict[str, Optional[str]]]: 

693 """ 

694 Get well key→name mapping from ImageXpress metadata. 

695 

696 Args: 

697 plate_path: Path to the plate folder (str or Path) 

698 

699 Returns: 

700 None - ImageXpress doesn't provide rich well names in metadata 

701 """ 

702 return None 

703 

704 def get_site_values(self, plate_path: Union[str, Path]) -> Optional[Dict[str, Optional[str]]]: 

705 """ 

706 Get site key→name mapping from ImageXpress metadata. 

707 

708 Args: 

709 plate_path: Path to the plate folder (str or Path) 

710 

711 Returns: 

712 None - ImageXpress doesn't provide rich site names in metadata 

713 """ 

714 return None 

715 

716 def get_z_index_values(self, plate_path: Union[str, Path]) -> Optional[Dict[str, Optional[str]]]: 

717 """ 

718 Get z_index key→name mapping from ImageXpress metadata. 

719 

720 Args: 

721 plate_path: Path to the plate folder (str or Path) 

722 

723 Returns: 

724 None - ImageXpress doesn't provide rich z_index names in metadata 

725 """ 

726 return None 

727 

728 

729 

730 

731# Set metadata handler class after class definition for automatic registration 

732from openhcs.microscopes.microscope_base import register_metadata_handler 

733ImageXpressHandler._metadata_handler_class = ImageXpressMetadataHandler 

734register_metadata_handler(ImageXpressHandler, ImageXpressMetadataHandler)