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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +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(
199 well=components['well'],
200 site=components['site'],
201 channel=components['channel'],
202 z_index=z_index,
203 extension=components['extension']
204 )
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))
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
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)
231 def _process_files_in_directory(self, directory: Path, fm: FileManager):
232 """
233 Process files directly in a directory to ensure complete metadata.
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.
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)
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
251 # Get the filename
252 img_file_name = img_file.name if isinstance(img_file, Path) else os.path.basename(str(img_file))
254 # Parse the original filename to extract components
255 components = self.parser.parse_filename(img_file_name)
257 if not components:
258 continue
260 # Check if we need to add missing metadata
261 needs_rebuild = False
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)
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)
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 )
286 # Only rename if the filename actually changed
287 if new_name != img_file_name:
288 new_path = directory / new_name
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
303class ImageXpressFilenameParser(FilenameParser):
304 """
305 Parser for ImageXpress microscope filenames.
307 Handles standard ImageXpress format filenames like:
308 - A01_s001_w1.tif
309 - A01_s1_w1_z1.tif
310 """
312 # Regular expression pattern for ImageXpress filenames
313 _pattern = re.compile(r'(?:.*?_)?([A-Z]\d+)(?:_s(\d+|\{[^\}]*\}))?(?:_w(\d+|\{[^\}]*\}))?(?:_z(\d+|\{[^\}]*\}))?(\.\w+)?$')
315 def __init__(self, filemanager=None, pattern_format=None):
316 """
317 Initialize the parser.
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
327 @classmethod
328 def can_parse(cls, filename: Union[str, Any]) -> bool:
329 """
330 Check if this parser can parse the given filename.
332 Args:
333 filename: Filename to check (str or VirtualPath)
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
343 # Check if the filename matches the ImageXpress pattern
344 return bool(cls._pattern.match(basename))
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.
353 Args:
354 filename: Filename to parse (str or VirtualPath)
356 Returns:
357 dict or None: Dictionary with extracted components or None if parsing fails
358 """
360 basename = Path(str(filename)).name
362 match = self._pattern.match(basename)
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()
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)
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 }
382 return result
383 else:
384 logger.debug("Could not parse ImageXpress filename: %s", filename)
385 return None
387 def extract_row_column(self, well: str) -> Tuple[str, str]:
388 """
389 Extract row and column from ImageXpress well identifier.
391 Args:
392 well (str): Well identifier (e.g., 'A01', 'C04')
394 Returns:
395 Tuple[str, str]: (row, column) where row is like 'A', 'C' and column is like '01', '04'
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}")
403 # ImageXpress format: A01, B02, C04, etc.
404 row = well[0]
405 col = well[1:]
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'")
410 return row, col
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.
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)
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.")
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}")
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}")
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}")
455 base_name = "".join(parts)
456 return f"{base_name}{extension}"
459class ImageXpressMetadataHandler(MetadataHandler):
460 """
461 Metadata handler for ImageXpress microscopes.
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.
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
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.
481 Args:
482 plate_path: Path to the plate folder
483 context: Optional ProcessingContext (not used)
485 Returns:
486 Path to the HTD file
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__}")
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}")
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)
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
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
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.")
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.
529 Args:
530 plate_path: Path to the plate folder
531 context: Optional ProcessingContext (not used)
533 Returns:
534 (grid_rows, grid_cols) - UPDATED: Now returns (rows, cols) for MIST compatibility
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)
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
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
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}")
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)
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)
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
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}")
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.
592 Args:
593 plate_path: Path to the plate folder
594 context: Optional ProcessingContext (not used)
596 Returns:
597 Pixel size in micrometers
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")
615 # Attempt to read tags from the first found image
616 first_image_path = image_files[0]
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__}")
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))
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))
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}")
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}")
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.
656 Args:
657 plate_path: Path to the plate folder (str or Path)
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)
667 # Read HTD file content
668 encodings_to_try = ['utf-8', 'windows-1252', 'latin-1', 'cp1252', 'iso-8859-1']
669 htd_content = None
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
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
683 # Extract channel information from WaveName entries
684 channel_mapping = {}
686 # ImageXpress stores channel names as WaveName1, WaveName2, etc.
687 wave_pattern = re.compile(r'"WaveName(\d+)", "([^"]*)"')
688 matches = wave_pattern.findall(htd_content)
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
694 return channel_mapping if channel_mapping else None
696 except Exception as e:
697 logger.debug(f"Could not extract channel names from ImageXpress metadata: {e}")
698 return None
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.
704 Args:
705 plate_path: Path to the plate folder (str or Path)
707 Returns:
708 None - ImageXpress doesn't provide rich well names in metadata
709 """
710 return None
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.
716 Args:
717 plate_path: Path to the plate folder (str or Path)
719 Returns:
720 None - ImageXpress doesn't provide rich site names in metadata
721 """
722 return None
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.
728 Args:
729 plate_path: Path to the plate folder (str or Path)
731 Returns:
732 None - ImageXpress doesn't provide rich z_index names in metadata
733 """
734 return None
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)