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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""
2ImageXpress microscope implementations for openhcs.
4This module provides concrete implementations of FilenameParser and MetadataHandler
5for ImageXpress microscopes.
6"""
8import logging
9import os
10import re
11from pathlib import Path
12from typing import Any, Dict, List, Optional, Tuple, Union, Type
14import tifffile
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)
23logger = logging.getLogger(__name__)
25class ImageXpressHandler(MicroscopeHandler):
26 """
27 MicroscopeHandler implementation for Molecular Devices ImageXpress systems.
29 This handler binds the ImageXpress filename parser and metadata handler,
30 enforcing semantic alignment between file layout parsing and metadata resolution.
31 """
33 # Explicit microscope type for proper registration
34 _microscope_type = 'imagexpress'
36 # Class attribute for automatic metadata handler registration (set after class definition)
37 _metadata_handler_class = None
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)
45 @property
46 def common_dirs(self) -> List[str]:
47 """Subdirectory names commonly used by ImageXpress"""
48 return ['TimePoint_1']
50 @property
51 def microscope_type(self) -> str:
52 """Microscope type identifier (for interface enforcement only)."""
53 return 'imagexpress'
55 @property
56 def metadata_handler_class(self) -> Type[MetadataHandler]:
57 """Metadata handler class (for interface enforcement only)."""
58 return ImageXpressMetadataHandler
60 @property
61 def compatible_backends(self) -> List[Backend]:
62 """
63 ImageXpress is compatible with DISK backend only.
65 Legacy microscope format with standard file operations.
66 """
67 return [Backend.DISK]
71 # Uses default workspace initialization from base class
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.
78 This method performs preparation but does not determine the final image directory.
80 Args:
81 workspace_path: Path to the symlinked workspace
82 filemanager: FileManager instance for file operations
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)
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)
98 # Check if any subdirectory contains common_dirs string
99 common_dir_found = False
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
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)
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)
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)
123 # Return the image directory
124 return workspace_path
126 def _flatten_zsteps(self, directory: Path, fm: FileManager):
127 """
128 Process Z-step folders in the given directory.
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)
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)
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)
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)
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
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))
170 # Sort by Z index
171 z_folders.sort(key=lambda x: x[0])
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)
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
185 # Get the filename
186 img_file_name = img_file.name if isinstance(img_file, Path) else os.path.basename(str(img_file))
188 # Parse the original filename to extract components
189 components = self.parser.parse_filename(img_file_name)
191 if not components: 191 ↛ 192line 191 didn't jump to line 192 because the condition on line 191 was never true
192 continue
194 # Update the z_index in the components
195 components['z_index'] = z_index
197 # Use the parser to construct a new filename with the updated z_index
198 new_name = self.parser.construct_filename(**components)
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))
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
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)
225 def _process_files_in_directory(self, directory: Path, fm: FileManager):
226 """
227 Process files directly in a directory to ensure complete metadata.
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.
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)
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
245 # Get the filename
246 img_file_name = img_file.name if isinstance(img_file, Path) else os.path.basename(str(img_file))
248 # Parse the original filename to extract components
249 components = self.parser.parse_filename(img_file_name)
251 if not components:
252 continue
254 # Check if we need to add missing metadata
255 needs_rebuild = False
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)
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)
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)
274 # Only rename if the filename actually changed
275 if new_name != img_file_name:
276 new_path = directory / new_name
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
291class ImageXpressFilenameParser(FilenameParser):
292 """
293 Parser for ImageXpress microscope filenames.
295 Handles standard ImageXpress format filenames like:
296 - A01_s001_w1.tif
297 - A01_s1_w1_z1.tif
298 """
300 # Regular expression pattern for ImageXpress filenames
301 _pattern = re.compile(r'(?:.*?_)?([A-Z]\d+)(?:_s(\d+|\{[^\}]*\}))?(?:_w(\d+|\{[^\}]*\}))?(?:_z(\d+|\{[^\}]*\}))?(\.\w+)?$')
303 def __init__(self, filemanager=None, pattern_format=None):
304 """
305 Initialize the parser.
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
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
317 @classmethod
318 def can_parse(cls, filename: Union[str, Any]) -> bool:
319 """
320 Check if this parser can parse the given filename.
322 Args:
323 filename: Filename to check (str or VirtualPath)
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
333 # Check if the filename matches the ImageXpress pattern
334 return bool(cls._pattern.match(basename))
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.
343 Args:
344 filename: Filename to parse (str or VirtualPath)
346 Returns:
347 dict or None: Dictionary with extracted components or None if parsing fails
348 """
350 basename = Path(str(filename)).name
352 match = self._pattern.match(basename)
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()
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)
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 }
372 return result
373 else:
374 logger.debug("Could not parse ImageXpress filename: %s", filename)
375 return None
377 def extract_component_coordinates(self, component_value: str) -> Tuple[str, str]:
378 """
379 Extract coordinates from component identifier (typically well).
381 Args:
382 component_value (str): Component identifier (e.g., 'A01', 'C04')
384 Returns:
385 Tuple[str, str]: (row, column) where row is like 'A', 'C' and column is like '01', '04'
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}")
393 # ImageXpress format: A01, B02, C04, etc.
394 row = component_value[0]
395 col = component_value[1:]
397 if not row.isalpha() or not col.isdigit():
398 raise ValueError(f"Invalid ImageXpress component format: {component_value}. Expected format like 'A01', 'C04'")
400 return row, col
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.
406 This method now uses **kwargs to accept any component values dynamically,
407 making it compatible with the generic parser interface.
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
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.")
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}")
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}")
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}")
447 base_name = "".join(parts)
448 return f"{base_name}{extension}"
451class ImageXpressMetadataHandler(MetadataHandler):
452 """
453 Metadata handler for ImageXpress microscopes.
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.
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
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.
473 Args:
474 plate_path: Path to the plate folder
475 context: Optional ProcessingContext (not used)
477 Returns:
478 Path to the HTD file
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__}")
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}")
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)
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
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
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.")
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.
521 Args:
522 plate_path: Path to the plate folder
523 context: Optional ProcessingContext (not used)
525 Returns:
526 (grid_rows, grid_cols) - UPDATED: Now returns (rows, cols) for MIST compatibility
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)
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
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
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}")
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)
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)
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
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}")
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.
584 Args:
585 plate_path: Path to the plate folder
586 context: Optional ProcessingContext (not used)
588 Returns:
589 Pixel size in micrometers
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")
607 # Attempt to read tags from the first found image
608 first_image_path = image_files[0]
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__}")
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))
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))
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}")
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}")
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.
648 Args:
649 plate_path: Path to the plate folder (str or Path)
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)
659 # Read HTD file content
660 encodings_to_try = ['utf-8', 'windows-1252', 'latin-1', 'cp1252', 'iso-8859-1']
661 htd_content = None
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
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
675 # Extract channel information from WaveName entries
676 channel_mapping = {}
678 # ImageXpress stores channel names as WaveName1, WaveName2, etc.
679 wave_pattern = re.compile(r'"WaveName(\d+)", "([^"]*)"')
680 matches = wave_pattern.findall(htd_content)
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
686 return channel_mapping if channel_mapping else None
688 except Exception as e:
689 logger.debug(f"Could not extract channel names from ImageXpress metadata: {e}")
690 return None
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.
696 Args:
697 plate_path: Path to the plate folder (str or Path)
699 Returns:
700 None - ImageXpress doesn't provide rich well names in metadata
701 """
702 return None
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.
708 Args:
709 plate_path: Path to the plate folder (str or Path)
711 Returns:
712 None - ImageXpress doesn't provide rich site names in metadata
713 """
714 return None
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.
720 Args:
721 plate_path: Path to the plate folder (str or Path)
723 Returns:
724 None - ImageXpress doesn't provide rich z_index names in metadata
725 """
726 return None
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)