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

293 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +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( 

199 well=components['well'], 

200 site=components['site'], 

201 channel=components['channel'], 

202 z_index=z_index, 

203 extension=components['extension'] 

204 ) 

205 

206 # Create the new path in the parent directory 

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

208 

209 try: 

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

211 # Use replace_symlinks=True to allow overwriting existing symlinks 

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

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

214 except FileExistsError as e: 

215 # Propagate FileExistsError with clear message 

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

217 raise 

218 except Exception as e: 

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

220 raise 

221 

222 # Remove Z folders after all files have been moved 

223 for _, z_dir in z_folders: 

224 try: 

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

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

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

228 except Exception as e: 

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

230 

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

232 """ 

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

234 

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

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

237 z_index, this adds default values for missing components. 

238 

239 Args: 

240 directory: Path to directory containing image files 

241 fm: FileManager instance for file operations 

242 """ 

243 # List all image files in the directory 

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

245 

246 for img_file in img_files: 

247 # Skip if not a file 

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

249 continue 

250 

251 # Get the filename 

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

253 

254 # Parse the original filename to extract components 

255 components = self.parser.parse_filename(img_file_name) 

256 

257 if not components: 

258 continue 

259 

260 # Check if we need to add missing metadata 

261 needs_rebuild = False 

262 

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

264 if components['channel'] is None: 

265 components['channel'] = 1 

266 needs_rebuild = True 

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

268 

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

270 if components['z_index'] is None: 

271 components['z_index'] = 1 

272 needs_rebuild = True 

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

274 

275 # Only rebuild filename if we added missing components 

276 if needs_rebuild: 

277 # Construct new filename with complete metadata 

278 new_name = self.parser.construct_filename( 

279 well=components['well'], 

280 site=components['site'], 

281 channel=components['channel'], 

282 z_index=components['z_index'], 

283 extension=components['extension'] 

284 ) 

285 

286 # Only rename if the filename actually changed 

287 if new_name != img_file_name: 

288 new_path = directory / new_name 

289 

290 try: 

291 # Pass the backend parameter as required by Clause 306 

292 # Use replace_symlinks=True to allow overwriting existing symlinks 

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

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

295 except FileExistsError as e: 

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

297 raise 

298 except Exception as e: 

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

300 raise 

301 

302 

303class ImageXpressFilenameParser(FilenameParser): 

304 """ 

305 Parser for ImageXpress microscope filenames. 

306 

307 Handles standard ImageXpress format filenames like: 

308 - A01_s001_w1.tif 

309 - A01_s1_w1_z1.tif 

310 """ 

311 

312 # Regular expression pattern for ImageXpress filenames 

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

314 

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

316 """ 

317 Initialize the parser. 

318 

319 Args: 

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

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

322 """ 

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

324 self.filemanager = filemanager 

325 self.pattern_format = pattern_format 

326 

327 @classmethod 

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

329 """ 

330 Check if this parser can parse the given filename. 

331 

332 Args: 

333 filename: Filename to check (str or VirtualPath) 

334 

335 Returns: 

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

337 """ 

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

339 # 🔒 Clause 17 — VFS Boundary Method 

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

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

342 

343 # Check if the filename matches the ImageXpress pattern 

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

345 

346 # 🔒 Clause 17 — VFS Boundary Method 

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

348 # but is needed for filename parsing during runtime. 

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

350 """ 

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

352 

353 Args: 

354 filename: Filename to parse (str or VirtualPath) 

355 

356 Returns: 

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

358 """ 

359 

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

361 

362 match = self._pattern.match(basename) 

363 

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

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

366 

367 #handle {} place holders 

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

369 site = parse_comp(site_str) 

370 channel = parse_comp(channel_str) 

371 z_index = parse_comp(z_str) 

372 

373 # Use the parsed components in the result 

374 result = { 

375 'well': well, 

376 'site': site, 

377 'channel': channel, 

378 'z_index': z_index, 

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

380 } 

381 

382 return result 

383 else: 

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

385 return None 

386 

387 def extract_row_column(self, well: str) -> Tuple[str, str]: 

388 """ 

389 Extract row and column from ImageXpress well identifier. 

390 

391 Args: 

392 well (str): Well identifier (e.g., 'A01', 'C04') 

393 

394 Returns: 

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

396 

397 Raises: 

398 ValueError: If well format is invalid 

399 """ 

400 if not well or len(well) < 2: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true

401 raise ValueError(f"Invalid well format: {well}") 

402 

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

404 row = well[0] 

405 col = well[1:] 

406 

407 if not row.isalpha() or not col.isdigit(): 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 raise ValueError(f"Invalid ImageXpress well format: {well}. Expected format like 'A01', 'C04'") 

409 

410 return row, col 

411 

412 def construct_filename(self, well: str, site: Optional[Union[int, str]] = None, 

413 channel: Optional[int] = None, 

414 z_index: Optional[Union[int, str]] = None, 

415 extension: str = '.tif', 

416 site_padding: int = 3, z_padding: int = 3) -> str: 

417 """ 

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

419 

420 Args: 

421 well (str): Well ID (e.g., 'A01') 

422 site (int or str, optional): Site number or placeholder string (e.g., '{iii}') 

423 channel (int, optional): Channel number 

424 z_index (int or str, optional): Z-index or placeholder string (e.g., '{zzz}') 

425 extension (str, optional): File extension 

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

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

428 

429 Returns: 

430 str: Constructed filename 

431 """ 

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

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

434 

435 parts = [well] 

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

437 if isinstance(site, str): 

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

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

440 else: 

441 # Otherwise, format it as a padded integer 

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

443 

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

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

446 

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

448 if isinstance(z_index, str): 

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

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

451 else: 

452 # Otherwise, format it as a padded integer 

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

454 

455 base_name = "".join(parts) 

456 return f"{base_name}{extension}" 

457 

458 

459class ImageXpressMetadataHandler(MetadataHandler): 

460 """ 

461 Metadata handler for ImageXpress microscopes. 

462 

463 Handles finding and parsing HTD files for ImageXpress microscopes. 

464 Inherits fallback values from MetadataHandler ABC. 

465 """ 

466 def __init__(self, filemanager: FileManager): 

467 """ 

468 Initialize the metadata handler. 

469 

470 Args: 

471 filemanager: FileManager instance for file operations. 

472 """ 

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

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

475 

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

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

478 """ 

479 Find the HTD file for an ImageXpress plate. 

480 

481 Args: 

482 plate_path: Path to the plate folder 

483 context: Optional ProcessingContext (not used) 

484 

485 Returns: 

486 Path to the HTD file 

487 

488 Raises: 

489 MetadataNotFoundError: If no HTD file is found 

490 TypeError: If plate_path is not a valid path type 

491 """ 

492 # Ensure plate_path is a Path object 

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

494 plate_path = Path(plate_path) 

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

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

497 

498 # Ensure the path exists 

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

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

501 

502 # Use filemanager to list files 

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

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

505 if htd_files: 

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

507 # Convert to Path if it's a string 

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

509 htd_file = Path(htd_file) 

510 

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

512 return htd_file 

513 

514 # Return the first file 

515 first_file = htd_files[0] 

516 if isinstance(first_file, str): 

517 return Path(first_file) 

518 return first_file 

519 

520 # 🔒 Clause 65 — No Fallback Logic 

521 # Fail loudly if no HTD file is found 

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

523 

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

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

526 """ 

527 Get grid dimensions for stitching from HTD file. 

528 

529 Args: 

530 plate_path: Path to the plate folder 

531 context: Optional ProcessingContext (not used) 

532 

533 Returns: 

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

535 

536 Raises: 

537 MetadataNotFoundError: If no HTD file is found 

538 ValueError: If grid dimensions cannot be determined from metadata 

539 """ 

540 htd_file = self.find_metadata_file(plate_path, context) 

541 

542 # Parse HTD file 

543 try: 

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

545 # Try multiple encodings in order of likelihood 

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

547 htd_content = None 

548 

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

550 try: 

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

552 htd_content = f.read() 

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

554 break 

555 except UnicodeDecodeError: 

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

557 continue 

558 

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

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

561 

562 # Extract grid dimensions - try multiple formats 

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

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

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

566 

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

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

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

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

571 

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

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

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

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

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

577 return grid_size_y, grid_size_x 

578 

579 # 🔒 Clause 65 — No Fallback Logic 

580 # Fail loudly if grid dimensions cannot be determined 

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

582 except Exception as e: 

583 # 🔒 Clause 65 — No Fallback Logic 

584 # Fail loudly on any error 

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

586 

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

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

589 """ 

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

591 

592 Args: 

593 plate_path: Path to the plate folder 

594 context: Optional ProcessingContext (not used) 

595 

596 Returns: 

597 Pixel size in micrometers 

598 

599 Raises: 

600 ValueError: If pixel size cannot be determined from metadata 

601 """ 

602 # This implementation requires: 

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

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

605 # 3. Images are in TIFF format. 

606 try: 

607 # Use filemanager to list potential image files 

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

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

610 if not image_files: 610 ↛ 613line 610 didn't jump to line 613 because the condition on line 610 was never true

611 # 🔒 Clause 65 — No Fallback Logic 

612 # Fail loudly if no image files are found 

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

614 

615 # Attempt to read tags from the first found image 

616 first_image_path = image_files[0] 

617 

618 # Convert to Path if it's a string 

619 if isinstance(first_image_path, str): 619 ↛ 621line 619 didn't jump to line 621 because the condition on line 619 was always true

620 first_image_path = Path(first_image_path) 

621 elif not isinstance(first_image_path, Path): 

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

623 

624 # Use the path with tifffile 

625 with tifffile.TiffFile(first_image_path) as tif: 

626 # Try to get ImageDescription tag 

627 if tif.pages[0].tags.get('ImageDescription'): 627 ↛ 645line 627 didn't jump to line 645

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

629 # Look for spatial calibration using regex 

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

631 if match: 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true

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

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

634 return float(match.group(1)) 

635 

636 # Alternative pattern for some formats 

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

638 if match: 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true

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

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

641 return float(match.group(1)) 

642 

643 # 🔒 Clause 65 — No Fallback Logic 

644 # Fail loudly if pixel size cannot be determined 

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

646 

647 except Exception as e: 

648 # 🔒 Clause 65 — No Fallback Logic 

649 # Fail loudly on any error 

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

651 

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

653 """ 

654 Get channel key→name mapping from ImageXpress HTD file. 

655 

656 Args: 

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

658 

659 Returns: 

660 Dict mapping channel IDs to channel names from metadata 

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

662 """ 

663 try: 

664 # Find and parse HTD file 

665 htd_file = self.find_metadata_file(plate_path) 

666 

667 # Read HTD file content 

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

669 htd_content = None 

670 

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

672 try: 

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

674 htd_content = f.read() 

675 break 

676 except UnicodeDecodeError: 

677 continue 

678 

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

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

681 return None 

682 

683 # Extract channel information from WaveName entries 

684 channel_mapping = {} 

685 

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

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

688 matches = wave_pattern.findall(htd_content) 

689 

690 for wave_num, wave_name in matches: 

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

692 channel_mapping[wave_num] = wave_name 

693 

694 return channel_mapping if channel_mapping else None 

695 

696 except Exception as e: 

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

698 return None 

699 

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

701 """ 

702 Get well key→name mapping from ImageXpress metadata. 

703 

704 Args: 

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

706 

707 Returns: 

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

709 """ 

710 return None 

711 

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

713 """ 

714 Get site key→name mapping from ImageXpress metadata. 

715 

716 Args: 

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

718 

719 Returns: 

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

721 """ 

722 return None 

723 

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

725 """ 

726 Get z_index key→name mapping from ImageXpress metadata. 

727 

728 Args: 

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

730 

731 Returns: 

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

733 """ 

734 return None 

735 

736 

737 

738 

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

740from openhcs.microscopes.microscope_base import register_metadata_handler 

741ImageXpressHandler._metadata_handler_class = ImageXpressMetadataHandler 

742register_metadata_handler(ImageXpressHandler, ImageXpressMetadataHandler)