From d7fab365055d7c05545f893954c9a2cb4dfb9495 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 10 Feb 2025 22:14:49 -0800 Subject: [PATCH 01/10] refactor: improve laser autofocus controller - Add type hints and comprehensive docstrings - Improve error handling and logging --- software/control/core/core.py | 406 ++++++++++++++++++++-------------- 1 file changed, 241 insertions(+), 165 deletions(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index b6678a5c..b30a0a60 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -3860,21 +3860,21 @@ def add_region(self, well_id, center_x, center_y, scan_size_mm, overlap_percent= # Use scan_size_mm as height, width is 0.6 * height height_mm = scan_size_mm width_mm = scan_size_mm * 0.6 - + # Calculate steps for height and width separately steps_height = math.floor(height_mm / step_size_mm) steps_width = math.floor(width_mm / step_size_mm) - + # Calculate actual dimensions actual_scan_height_mm = (steps_height - 1) * step_size_mm + fov_size_mm actual_scan_width_mm = (steps_width - 1) * step_size_mm + fov_size_mm - + steps_height = max(1, steps_height) steps_width = max(1, steps_width) half_steps_height = (steps_height - 1) / 2 half_steps_width = (steps_width - 1) / 2 - + for i in range(steps_height): row = [] y = center_y + (i - half_steps_height) * step_size_mm @@ -3916,8 +3916,13 @@ def add_region(self, well_id, center_x, center_y, scan_size_mm, overlap_percent= y = center_y + (i - half_steps) * step_size_mm for j in range(steps): x = center_x + (j - half_steps) * step_size_mm - if shape == "Square" or shape == "Rectangle" or ( - shape == "Circle" and self._is_in_circle(x, y, center_x, center_y, radius_squared, fov_size_mm_half) + if ( + shape == "Square" + or shape == "Rectangle" + or ( + shape == "Circle" + and self._is_in_circle(x, y, center_x, center_y, radius_squared, fov_size_mm_half) + ) ): if self.validate_coordinates(x, y): row.append((x, y)) @@ -4059,20 +4064,30 @@ def add_manual_region(self, shape_coords, overlap_percent): # valid_points = grid_points[mask] def corners(x_mm, y_mm, fov): - center_to_corner = fov/2 + center_to_corner = fov / 2 return ( (x_mm + center_to_corner, y_mm + center_to_corner), (x_mm - center_to_corner, y_mm + center_to_corner), (x_mm - center_to_corner, y_mm - center_to_corner), - (x_mm + center_to_corner, y_mm - center_to_corner) + (x_mm + center_to_corner, y_mm - center_to_corner), ) + valid_points = [] for x_center, y_center in grid_points: if not self.validate_coordinates(x_center, y_center): - self._log.debug(f"Manual coords: ignoring {x_center=},{y_center=} because it is outside our movement range.") + self._log.debug( + f"Manual coords: ignoring {x_center=},{y_center=} because it is outside our movement range." + ) continue - if not self._is_in_polygon(x_center, y_center, shape_coords) and not any([self._is_in_polygon(x_corner, y_corner, shape_coords) for (x_corner, y_corner) in corners(x_center, y_center, fov_size_mm)]): - self._log.debug(f"Manual coords: ignoring {x_center=},{y_center=} because no corners or center are in poly. (corners={corners(x_center, y_center, fov_size_mm)}") + if not self._is_in_polygon(x_center, y_center, shape_coords) and not any( + [ + self._is_in_polygon(x_corner, y_corner, shape_coords) + for (x_corner, y_corner) in corners(x_center, y_center, fov_size_mm) + ] + ): + self._log.debug( + f"Manual coords: ignoring {x_center=},{y_center=} because no corners or center are in poly. (corners={corners(x_center, y_center, fov_size_mm)}" + ) continue valid_points.append((x_center, y_center)) @@ -4493,7 +4508,27 @@ def __init__( print(e) pass - def initialize_manual(self, x_offset, y_offset, width, height, pixel_to_um, x_reference, write_to_cache=True): + def initialize_manual( + self, + x_offset: float, + y_offset: float, + width: int, + height: int, + pixel_to_um: float, + x_reference: float, + write_to_cache: bool = True, + ) -> None: + """Initialize laser autofocus with manual parameters. + + Args: + x_offset: X offset for ROI in pixels + y_offset: Y offset for ROI in pixels + width: Width of ROI in pixels + height: Height of ROI in pixels + pixel_to_um: Conversion factor from pixels to micrometers + x_reference: Reference X position in pixels (relative to full sensor) + write_to_cache: Whether to save parameters to cache file + """ cache_string = ",".join( [str(x_offset), str(y_offset), str(width), str(height), str(pixel_to_um), str(x_reference)] ) @@ -4501,244 +4536,285 @@ def initialize_manual(self, x_offset, y_offset, width, height, pixel_to_um, x_re cache_path = Path("cache/laser_af_reference_plane.txt") cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_text(cache_string) + # x_reference is relative to the full sensor self.pixel_to_um = pixel_to_um - self.x_offset = int((x_offset // 8) * 8) - self.y_offset = int((y_offset // 2) * 2) + self.x_offset = int((x_offset // 8) * 8) # Round to multiple of 8 + self.y_offset = int((y_offset // 2) * 2) # Round to multiple of 2 self.width = int((width // 8) * 8) self.height = int((height // 2) * 2) self.x_reference = x_reference - self.x_offset # self.x_reference is relative to the cropped region self.camera.set_ROI(self.x_offset, self.y_offset, self.width, self.height) self.is_initialized = True - def initialize_auto(self): + def initialize_auto(self) -> bool: + """Automatically initialize laser autofocus by finding the spot and calibrating. - # first find the region to crop - # then calculate the convert factor + This method: + 1. Finds the laser spot on full sensor + 2. Sets up ROI around the spot + 3. Calibrates pixel-to-um conversion using two z positions + 4. Sets initial reference position + Returns: + bool: True if initialization successful, False if any step fails + """ # set camera to use full sensor self.camera.set_ROI(0, 0, None, None) # set offset first self.camera.set_ROI(0, 0, 3088, 2064) + # update camera settings self.camera.set_exposure_time(FOCUS_CAMERA_EXPOSURE_TIME_MS) self.camera.set_analog_gain(FOCUS_CAMERA_ANALOG_GAIN) - # turn on the laser + # Find initial spot position self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # get laser spot location - x, y = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + _log.error("Failed to find laser spot during initialization") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x, y = result - # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # Set up ROI around spot x_offset = x - LASER_AF_CROP_WIDTH / 2 y_offset = y - LASER_AF_CROP_HEIGHT / 2 - print("laser spot location on the full sensor is (" + str(int(x)) + "," + str(int(y)) + ")") + _log.info(f"Laser spot location on the full sensor is ({int(x)}, {int(y)})") - # set camera crop self.initialize_manual(x_offset, y_offset, LASER_AF_CROP_WIDTH, LASER_AF_CROP_HEIGHT, 1, x) - # turn on laser + # Calibrate pixel-to-um conversion self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # move z to - 6 um + # Move to first position and measure self.stage.move_z(-0.018) self.stage.move_z(0.012) time.sleep(0.02) - # measure - x0, y0 = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + _log.error("Failed to find laser spot during calibration (position 1)") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x0, y0 = result - # move z to 6 um + # Move to second position and measure self.stage.move_z(0.006) time.sleep(0.02) - # measure - x1, y1 = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + _log.error("Failed to find laser spot during calibration (position 2)") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x1, y1 = result - # turn off laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # Calculate conversion factor if x1 - x0 == 0: - # for simulation - self.pixel_to_um = 0.4 + self.pixel_to_um = 0.4 # Simulation value + _log.warning("Using simulation value for pixel_to_um conversion") else: - # calculate the conversion factor self.pixel_to_um = 6.0 / (x1 - x0) - print("pixel to um conversion factor is " + str(self.pixel_to_um) + " um/pixel") + _log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") - # set reference + # Set reference position self.x_reference = x1 + return True - if self.look_for_cache: - cache_path = "cache/laser_af_reference_plane.txt" - try: - x_offset = None - y_offset = None - width = None - height = None - pixel_to_um = None - x_reference = None - with open(cache_path, "r") as cache_file: - for line in cache_file: - value_list = line.split(",") - x_offset = float(value_list[0]) - y_offset = float(value_list[1]) - width = int(value_list[2]) - height = int(value_list[3]) - pixel_to_um = self.pixel_to_um - x_reference = self.x_reference + self.x_offset - break - cache_string = ",".join( - [str(x_offset), str(y_offset), str(width), str(height), str(pixel_to_um), str(x_reference)] - ) - cache_path = Path("cache/laser_af_reference_plane.txt") - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(cache_string) - except (FileNotFoundError, ValueError, IndexError) as e: - print("Unable to read laser AF state cache, exception below:") - print(e) - pass + def measure_displacement(self) -> float: + """Measure the displacement of the laser spot from the reference position. - def measure_displacement(self): + Returns: + float: Displacement in micrometers, or float('nan') if measurement fails + """ # turn on the laser self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # get laser spot location - x, y = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + + if result is None: + _log.error("Failed to detect laser spot during displacement measurement") + self.signal_displacement_um.emit(float("nan")) # Signal invalid measurement + return float("nan") + + x, y = result # calculate displacement displacement_um = (x - self.x_reference) * self.pixel_to_um self.signal_displacement_um.emit(displacement_um) return displacement_um - def move_to_target(self, target_um): + def move_to_target(self, target_um: float) -> bool: + """Move the stage to reach a target displacement from reference position. + + Args: + target_um: Target displacement in micrometers + + Returns: + bool: True if move was successful, False if measurement failed or displacement was out of range + """ current_displacement_um = self.measure_displacement() - print("Laser AF displacement: ", current_displacement_um) + _log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") + + if math.isnan(current_displacement_um): + _log.error("Cannot move to target: failed to measure current displacement") + return False if abs(current_displacement_um) > LASER_AF_RANGE: - print( - f"Warning: Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" + _log.warning( + f"Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" ) - um_to_move = 0 - else: - um_to_move = target_um - current_displacement_um + return False + + um_to_move = target_um - current_displacement_um + self.stage.move_z(um_to_move / 1000) # Convert to mm + + # Verify we reached the target + final_displacement = self.measure_displacement() + if math.isnan(final_displacement): + _log.error("Failed to verify final position") + return False - self.stage.move_z(um_to_move / 1000) + _log.debug(f"Final displacement: {final_displacement:.1f} μm") + return abs(final_displacement - target_um) < 1.0 # Success if within 1 μm - # update the displacement measurement - self.measure_displacement() + def set_reference(self) -> bool: + """Set the current spot position as the reference position. - def set_reference(self): + Returns: + bool: True if reference was set successfully, False if spot detection failed + """ # turn on the laser self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # get laser spot location - x, y = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + + if result is None: + _log.error("Failed to detect laser spot while setting reference") + return False + + x, y = result self.x_reference = x self.signal_displacement_um.emit(0) + _log.info(f"Set reference position to ({x:.1f}, {y:.1f})") + return True - def _caculate_centroid(self, image): - if self.has_two_interfaces == False: - h, w = image.shape - x, y = np.meshgrid(range(w), range(h)) - I = image.astype(float) - I = I - np.amin(I) - I[I / np.amax(I) < 0.2] = 0 - x = np.sum(x * I) / np.sum(I) - y = np.sum(y * I) / np.sum(I) - return x, y - else: - I = image - # get the y position of the spots - tmp = np.sum(I, axis=1) - y0 = np.argmax(tmp) - # crop along the y axis - I = I[y0 - 96 : y0 + 96, :] - # signal along x - tmp = np.sum(I, axis=0) - # find peaks - peak_locations, _ = scipy.signal.find_peaks(tmp, distance=100) - idx = np.argsort(tmp[peak_locations]) - peak_0_location = peak_locations[idx[-1]] - peak_1_location = peak_locations[ - idx[-2] - ] # for air-glass-water, the smaller peak corresponds to the glass-water interface - self.spot_spacing_pixels = peak_1_location - peak_0_location - """ - # find peaks - alternative - if self.spot_spacing_pixels is not None: - peak_locations,_ = scipy.signal.find_peaks(tmp,distance=100) - idx = np.argsort(tmp[peak_locations]) - peak_0_location = peak_locations[idx[-1]] - peak_1_location = peak_locations[idx[-2]] # for air-glass-water, the smaller peak corresponds to the glass-water interface - self.spot_spacing_pixels = peak_1_location-peak_0_location - else: - peak_0_location = np.argmax(tmp) - peak_1_location = peak_0_location + self.spot_spacing_pixels - """ - # choose which surface to use - if self.use_glass_top: - x1 = peak_1_location - else: - x1 = peak_0_location - # find centroid - h, w = I.shape - x, y = np.meshgrid(range(w), range(h)) - I = I[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - x = x[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - y = y[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - I = I.astype(float) - I = I - np.amin(I) - I[I / np.amax(I) < 0.1] = 0 - x1 = np.sum(x * I) / np.sum(I) - y1 = np.sum(y * I) / np.sum(I) - return x1, y0 - 96 + y1 - - def _get_laser_spot_centroid(self): + def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: + """Get the centroid location of the laser spot. + + Averages multiple measurements to improve accuracy. The number of measurements + is controlled by LASER_AF_AVERAGING_N. + + Returns: + Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails + """ # disable camera callback self.camera.disable_callback() + + successful_detections = 0 tmp_x = 0 tmp_y = 0 + for i in range(LASER_AF_AVERAGING_N): - # send camera trigger - if self.liveController.trigger_mode == TriggerMode.SOFTWARE: - self.camera.send_trigger() - elif self.liveController.trigger_mode == TriggerMode.HARDWARE: - # self.microcontroller.send_hardware_trigger(control_illumination=True,illumination_on_time_us=self.camera.exposure_time*1000) - pass # to edit - # read camera frame - image = self.camera.read_frame() - self.image = image - # optionally display the image - if LASER_AF_DISPLAY_SPOT_IMAGE: - self.image_to_display.emit(image) - # calculate centroid - x, y = self._caculate_centroid(image) - tmp_x = tmp_x + x - tmp_y = tmp_y + y - x = tmp_x / LASER_AF_AVERAGING_N - y = tmp_y / LASER_AF_AVERAGING_N - return x, y - - def get_image(self): + try: + # send camera trigger + if self.liveController.trigger_mode == TriggerMode.SOFTWARE: + self.camera.send_trigger() + elif self.liveController.trigger_mode == TriggerMode.HARDWARE: + # self.microcontroller.send_hardware_trigger(control_illumination=True,illumination_on_time_us=self.camera.exposure_time*1000) + pass # to edit + + # read camera frame + image = self.camera.read_frame() + if image is None: + _log.warning(f"Failed to read frame {i+1}/{LASER_AF_AVERAGING_N}") + continue + + self.image = image # store for debugging + + # optionally display the image + if LASER_AF_DISPLAY_SPOT_IMAGE: + self.image_to_display.emit(image) + + # calculate centroid + result = utils.find_spot_location(image) + if result is None: + _log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") + continue + + x, y = result + tmp_x += x + tmp_y += y + successful_detections += 1 + + except Exception as e: + _log.error(f"Error processing frame {i+1}/{LASER_AF_AVERAGING_N}: {str(e)}") + continue + + # Check if we got enough successful detections + if successful_detections == 0: + _log.error(f"No successful detections") + return None + + # Calculate average position from successful detections + x = tmp_x / successful_detections + y = tmp_y / successful_detections + + _log.debug(f"Spot centroid found at ({x:.1f}, {y:.1f}) from {successful_detections} detections") + return (x, y) + + def get_image(self) -> Optional[np.ndarray]: + """Capture and display a single image from the laser autofocus camera. + + Turns the laser on, captures an image, displays it, then turns the laser off. + + Returns: + Optional[np.ndarray]: The captured image, or None if capture failed + """ # turn on the laser self.microcontroller.turn_on_AF_laser() - self.microcontroller.wait_till_operation_is_completed() # send trigger, grab image and display image - self.camera.send_trigger() - image = self.camera.read_frame() - self.image_to_display.emit(image) - # turn off the laser - self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() - return image + + try: + # send trigger, grab image and display image + self.camera.send_trigger() + image = self.camera.read_frame() + + if image is None: + _log.error("Failed to read frame in get_image") + return None + + self.image_to_display.emit(image) + return image + + except Exception as e: + _log.error(f"Error capturing image: {str(e)}") + return None + + finally: + # turn off the laser + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() From 7acebb18ea8cd1ea876bdef04951d90dd9adfb12 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 11 Feb 2025 02:24:24 -0800 Subject: [PATCH 02/10] feat: add robust spot detection --- software/control/utils.py | 152 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/software/control/utils.py b/software/control/utils.py index cdcb479f..949af6ef 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -5,6 +5,8 @@ import numpy as np from scipy.ndimage import label import os +from typing import Optional, Tuple +from enum import Enum, auto import squid.logging @@ -165,3 +167,153 @@ def ensure_directory_exists(raw_string_path: str): path: pathlib.Path = pathlib.Path(raw_string_path) _log.debug(f"Making sure directory '{path}' exists.") path.mkdir(parents=True, exist_ok=True) + + +class SpotDetectionMode(Enum): + """Specifies which spot to detect when multiple spots are present. + + SINGLE: Expect and detect single spot + DUAL_RIGHT: In dual-spot case, use rightmost spot + DUAL_LEFT: In dual-spot case, use leftmost spot + MULTI_RIGHT: In multi-spot case, use rightmost spot + MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot + """ + + SINGLE = auto() + DUAL_RIGHT = auto() + DUAL_LEFT = auto() + MULTI_RIGHT = auto() + MULTI_SECOND_RIGHT = auto() + + +def find_spot_location( + image: np.ndarray, mode: SpotDetectionMode = SpotDetectionMode.SINGLE, params: Optional[dict] = None +) -> Optional[Tuple[float, float]]: + """Find the location of a spot in an image. + + Args: + image: Input grayscale image as numpy array + mode: Which spot to detect when multiple spots are present + params: Dictionary of parameters for spot detection. If None, default parameters will be used. + Supported parameters: + - y_window (int): Half-height of y-axis crop (default: 96) + - x_window (int): Half-width of centroid window (default: 20) + - peak_width (int): Minimum width of peaks (default: 10) + - peak_distance (int): Minimum distance between peaks (default: 10) + - peak_prominence (float): Minimum peak prominence (default: 100) + - intensity_threshold (float): Threshold for intensity filtering (default: 0.1) + - spot_spacing (int): Expected spacing between spots for multi-spot modes (default: 100) + + Returns: + Optional[Tuple[float, float]]: (x,y) coordinates of selected spot, or None if detection fails + + Raises: + ValueError: If image is invalid or mode is incompatible with detected spots + """ + # Input validation + if image is None or not isinstance(image, np.ndarray): + raise ValueError("Invalid input image") + + # Default parameters + default_params = { + "y_window": 96, # Half-height of y-axis crop + "x_window": 20, # Half-width of centroid window + "min_peak_width": 10, # Minimum width of peaks + "min_peak_distance": 10, # Minimum distance between peaks + "mim_peak_prominence": 100, # Minimum peak prominence + "intensity_threshold": 0.1, # Threshold for intensity filtering + "spot_spacing": 100, # Expected spacing between spots + } + + if params is not None: + default_params.update(params) + p = default_params + + try: + # Get the y position of the spots + intensity_profile = np.sum(image, axis=1) + if np.all(intensity_profile == 0): + raise ValueError("No spots detected in image") + + peak_y = np.argmax(intensity_profile) + + # Validate peak_y location + if peak_y < p["y_window"] or peak_y > image.shape[0] - p["y_window"]: + raise ValueError("Spot too close to image edge") + + # Crop along the y axis + cropped_image = image[peak_y - p["y_window"] : peak_y + p["y_window"], :] + + # Get signal along x + intensity_profile = np.sum(cropped_image, axis=0) + + # Find all peaks + peaks = signal.find_peaks( + intensity_profile, + width=p["min_peak_width"], + distance=p["min_peak_distance"], + prominence=p["min_peak_prominence"], + ) + peak_locations = peaks[0] + + if len(peak_locations) == 0: + raise ValueError("No peaks detected") + + # Handle different spot detection modes + if mode == SpotDetectionMode.SINGLE: + if len(peak_locations) > 1: + raise ValueError(f"Found {len(peak_locations)} peaks but expected single peak") + peak_x = peak_locations[0] + + elif mode == SpotDetectionMode.DUAL_RIGHT: + peak_x = peak_locations[-1] + + elif mode == SpotDetectionMode.DUAL_LEFT: + peak_x = peak_locations[0] + + elif mode == SpotDetectionMode.MULTI_RIGHT: + peak_x = peak_locations[-1] + + elif mode == SpotDetectionMode.MULTI_SECOND_RIGHT: + peak_x = peak_locations[-2] + peak_x = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + peak_x = peak_x - p["spot_spacing"] + else: + raise ValueError(f"Unknown spot detection mode: {mode}") + + # Calculate centroid in window around selected peak + return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + + except Exception as e: + _log.error(f"Error in spot detection: {str(e)}") + return None + + +def _calculate_spot_centroid(cropped_image: np.ndarray, peak_x: int, peak_y: int, params: dict) -> Tuple[float, float]: + """Calculate precise centroid location in window around peak.""" + h, w = cropped_image.shape + x, y = np.meshgrid(range(w), range(h)) + + # Crop region around the peak + intensity_window = cropped_image[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + x_coords = x[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + y_coords = y[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + + # Process intensity values + intensity_window = intensity_window.astype(float) + intensity_window = intensity_window - np.amin(intensity_window) + if np.amax(intensity_window) > 0: # Avoid division by zero + intensity_window[intensity_window / np.amax(intensity_window) < params["intensity_threshold"]] = 0 + + # Calculate centroid + sum_intensity = np.sum(intensity_window) + if sum_intensity == 0: + raise ValueError("No significant intensity in centroid window") + + centroid_x = np.sum(x_coords * intensity_window) / sum_intensity + centroid_y = np.sum(y_coords * intensity_window) / sum_intensity + + # Convert back to original image coordinates + centroid_y = peak_y - params["y_window"] + centroid_y + + return (centroid_x, centroid_y) From a4834a8ca34892a0a4bbdf79473a15a7c59551d6 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 11 Feb 2025 02:39:22 -0800 Subject: [PATCH 03/10] bug fix --- software/control/core/core.py | 45 ++++++++++++++++++----------------- software/control/utils.py | 3 ++- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index b30a0a60..6b5c0ab1 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -3874,7 +3874,7 @@ def add_region(self, well_id, center_x, center_y, scan_size_mm, overlap_percent= half_steps_height = (steps_height - 1) / 2 half_steps_width = (steps_width - 1) / 2 - + for i in range(steps_height): row = [] y = center_y + (i - half_steps_height) * step_size_mm @@ -4468,6 +4468,7 @@ def __init__( look_for_cache=True, ): QObject.__init__(self) + self._log = squid.logging.get_logger(__class__.__name__) self.microcontroller = microcontroller self.camera = camera self.liveController = liveController @@ -4573,7 +4574,7 @@ def initialize_auto(self) -> bool: result = self._get_laser_spot_centroid() if result is None: - _log.error("Failed to find laser spot during initialization") + self._log.error("Failed to find laser spot during initialization") self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() return False @@ -4585,7 +4586,7 @@ def initialize_auto(self) -> bool: # Set up ROI around spot x_offset = x - LASER_AF_CROP_WIDTH / 2 y_offset = y - LASER_AF_CROP_HEIGHT / 2 - _log.info(f"Laser spot location on the full sensor is ({int(x)}, {int(y)})") + self._log.info(f"Laser spot location on the full sensor is ({int(x)}, {int(y)})") self.initialize_manual(x_offset, y_offset, LASER_AF_CROP_WIDTH, LASER_AF_CROP_HEIGHT, 1, x) @@ -4600,7 +4601,7 @@ def initialize_auto(self) -> bool: result = self._get_laser_spot_centroid() if result is None: - _log.error("Failed to find laser spot during calibration (position 1)") + self._log.error("Failed to find laser spot during calibration (position 1)") self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() return False @@ -4612,7 +4613,7 @@ def initialize_auto(self) -> bool: result = self._get_laser_spot_centroid() if result is None: - _log.error("Failed to find laser spot during calibration (position 2)") + self._log.error("Failed to find laser spot during calibration (position 2)") self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() return False @@ -4624,10 +4625,10 @@ def initialize_auto(self) -> bool: # Calculate conversion factor if x1 - x0 == 0: self.pixel_to_um = 0.4 # Simulation value - _log.warning("Using simulation value for pixel_to_um conversion") + self._log.warning("Using simulation value for pixel_to_um conversion") else: self.pixel_to_um = 6.0 / (x1 - x0) - _log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") + self._log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") # Set reference position self.x_reference = x1 @@ -4651,7 +4652,7 @@ def measure_displacement(self) -> float: self.microcontroller.wait_till_operation_is_completed() if result is None: - _log.error("Failed to detect laser spot during displacement measurement") + self._log.error("Failed to detect laser spot during displacement measurement") self.signal_displacement_um.emit(float("nan")) # Signal invalid measurement return float("nan") @@ -4671,14 +4672,14 @@ def move_to_target(self, target_um: float) -> bool: bool: True if move was successful, False if measurement failed or displacement was out of range """ current_displacement_um = self.measure_displacement() - _log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") + self._log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") if math.isnan(current_displacement_um): - _log.error("Cannot move to target: failed to measure current displacement") + self._log.error("Cannot move to target: failed to measure current displacement") return False if abs(current_displacement_um) > LASER_AF_RANGE: - _log.warning( + self._log.warning( f"Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" ) return False @@ -4689,10 +4690,10 @@ def move_to_target(self, target_um: float) -> bool: # Verify we reached the target final_displacement = self.measure_displacement() if math.isnan(final_displacement): - _log.error("Failed to verify final position") + self._log.error("Failed to verify final position") return False - _log.debug(f"Final displacement: {final_displacement:.1f} μm") + self._log.debug(f"Final displacement: {final_displacement:.1f} μm") return abs(final_displacement - target_um) < 1.0 # Success if within 1 μm def set_reference(self) -> bool: @@ -4713,13 +4714,13 @@ def set_reference(self) -> bool: self.microcontroller.wait_till_operation_is_completed() if result is None: - _log.error("Failed to detect laser spot while setting reference") + self._log.error("Failed to detect laser spot while setting reference") return False x, y = result self.x_reference = x self.signal_displacement_um.emit(0) - _log.info(f"Set reference position to ({x:.1f}, {y:.1f})") + self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") return True def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: @@ -4750,7 +4751,7 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: # read camera frame image = self.camera.read_frame() if image is None: - _log.warning(f"Failed to read frame {i+1}/{LASER_AF_AVERAGING_N}") + self._log.warning(f"Failed to read frame {i+1}/{LASER_AF_AVERAGING_N}") continue self.image = image # store for debugging @@ -4762,7 +4763,7 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: # calculate centroid result = utils.find_spot_location(image) if result is None: - _log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") + self._log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") continue x, y = result @@ -4771,19 +4772,19 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: successful_detections += 1 except Exception as e: - _log.error(f"Error processing frame {i+1}/{LASER_AF_AVERAGING_N}: {str(e)}") + self._log.error(f"Error processing frame {i+1}/{LASER_AF_AVERAGING_N}: {str(e)}") continue # Check if we got enough successful detections if successful_detections == 0: - _log.error(f"No successful detections") + self._log.error(f"No successful detections") return None # Calculate average position from successful detections x = tmp_x / successful_detections y = tmp_y / successful_detections - _log.debug(f"Spot centroid found at ({x:.1f}, {y:.1f}) from {successful_detections} detections") + self._log.debug(f"Spot centroid found at ({x:.1f}, {y:.1f}) from {successful_detections} detections") return (x, y) def get_image(self) -> Optional[np.ndarray]: @@ -4804,14 +4805,14 @@ def get_image(self) -> Optional[np.ndarray]: image = self.camera.read_frame() if image is None: - _log.error("Failed to read frame in get_image") + self._log.error("Failed to read frame in get_image") return None self.image_to_display.emit(image) return image except Exception as e: - _log.error(f"Error capturing image: {str(e)}") + self._log.error(f"Error capturing image: {str(e)}") return None finally: diff --git a/software/control/utils.py b/software/control/utils.py index 949af6ef..5c26d3d5 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -4,6 +4,7 @@ from numpy import std, square, mean import numpy as np from scipy.ndimage import label +from scipy import signal import os from typing import Optional, Tuple from enum import Enum, auto @@ -220,7 +221,7 @@ def find_spot_location( "x_window": 20, # Half-width of centroid window "min_peak_width": 10, # Minimum width of peaks "min_peak_distance": 10, # Minimum distance between peaks - "mim_peak_prominence": 100, # Minimum peak prominence + "min_peak_prominence": 100, # Minimum peak prominence "intensity_threshold": 0.1, # Threshold for intensity filtering "spot_spacing": 100, # Expected spacing between spots } From ae2b34e077f6e696fabfc670956f4936366022f5 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 22:24:51 -0800 Subject: [PATCH 04/10] move SpotDetectionMode from utils.py to _def.py --- software/control/_def.py | 17 +++++++++++++++++ software/control/utils.py | 20 +------------------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 32f650ef..893d2775 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -5,6 +5,7 @@ from configparser import ConfigParser import json import csv +from enum import Enum, auto import squid.logging @@ -240,6 +241,22 @@ class CAMERA_CONFIG: ROI_WIDTH_DEFAULT = 3104 ROI_HEIGHT_DEFAULT = 2084 +class SpotDetectionMode(Enum): + """Specifies which spot to detect when multiple spots are present. + + SINGLE: Expect and detect single spot + DUAL_RIGHT: In dual-spot case, use rightmost spot + DUAL_LEFT: In dual-spot case, use leftmost spot + MULTI_RIGHT: In multi-spot case, use rightmost spot + MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot + """ + + SINGLE = auto() + DUAL_RIGHT = auto() + DUAL_LEFT = auto() + MULTI_RIGHT = auto() + MULTI_SECOND_RIGHT = auto() + PRINT_CAMERA_FPS = True diff --git a/software/control/utils.py b/software/control/utils.py index 5c26d3d5..ea981442 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -7,7 +7,7 @@ from scipy import signal import os from typing import Optional, Tuple -from enum import Enum, auto +from ._def import SpotDetectionMode import squid.logging @@ -170,23 +170,6 @@ def ensure_directory_exists(raw_string_path: str): path.mkdir(parents=True, exist_ok=True) -class SpotDetectionMode(Enum): - """Specifies which spot to detect when multiple spots are present. - - SINGLE: Expect and detect single spot - DUAL_RIGHT: In dual-spot case, use rightmost spot - DUAL_LEFT: In dual-spot case, use leftmost spot - MULTI_RIGHT: In multi-spot case, use rightmost spot - MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot - """ - - SINGLE = auto() - DUAL_RIGHT = auto() - DUAL_LEFT = auto() - MULTI_RIGHT = auto() - MULTI_SECOND_RIGHT = auto() - - def find_spot_location( image: np.ndarray, mode: SpotDetectionMode = SpotDetectionMode.SINGLE, params: Optional[dict] = None ) -> Optional[Tuple[float, float]]: @@ -256,7 +239,6 @@ def find_spot_location( prominence=p["min_peak_prominence"], ) peak_locations = peaks[0] - if len(peak_locations) == 0: raise ValueError("No peaks detected") From e76b14d2bec3853d8b569546ae3bf148557273dc Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 22:31:21 -0800 Subject: [PATCH 05/10] add image cross correlation-based error detection and handling; add class constants --- software/control/core/core.py | 117 +++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 16 deletions(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index 6b5c0ab1..ac644699 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -3874,7 +3874,7 @@ def add_region(self, well_id, center_x, center_y, scan_size_mm, overlap_percent= half_steps_height = (steps_height - 1) / 2 half_steps_width = (steps_width - 1) / 2 - + for i in range(steps_height): row = [] y = center_y + (i - half_steps_height) * step_size_mm @@ -4454,6 +4454,11 @@ def get_surface_grid(self, x_range, y_range, num_points=50): class LaserAutofocusController(QObject): + DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 # if the displacement is within this window, we consider the move successful + SPOT_CROP_SIZE = 100 # Size of region to crop around spot for correlation + CORRELATION_THRESHOLD = 0.9 # Minimum correlation coefficient for valid alignment + PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 # Distance moved in um during calibration + image_to_display = Signal(np.ndarray) signal_displacement_um = Signal(float) @@ -4596,8 +4601,7 @@ def initialize_auto(self) -> bool: # Move to first position and measure self.stage.move_z(-0.018) - self.stage.move_z(0.012) - time.sleep(0.02) + self.stage.move_z(-0.018+self.PIXEL_TO_UM_CALIBRATION_DISTANCE/1000) result = self._get_laser_spot_centroid() if result is None: @@ -4608,7 +4612,7 @@ def initialize_auto(self) -> bool: x0, y0 = result # Move to second position and measure - self.stage.move_z(0.006) + self.stage.move_z(self.PIXEL_TO_UM_CALIBRATION_DISTANCE/1000) time.sleep(0.02) result = self._get_laser_spot_centroid() @@ -4627,7 +4631,7 @@ def initialize_auto(self) -> bool: self.pixel_to_um = 0.4 # Simulation value self._log.warning("Using simulation value for pixel_to_um conversion") else: - self.pixel_to_um = 6.0 / (x1 - x0) + self.pixel_to_um = PIXEL_TO_UM_CALIBRATION_DISTANCE / (x1 - x0) self._log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") # Set reference position @@ -4687,18 +4691,40 @@ def move_to_target(self, target_um: float) -> bool: um_to_move = target_um - current_displacement_um self.stage.move_z(um_to_move / 1000) # Convert to mm + # Verify using cross-correlation that spot is in same location as reference + if not self._verify_spot_alignment(): + self._log.warning("Cross correlation check failed - spots not well aligned") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) + return False + else: + self._log.info("Cross correlation check passed - spots are well aligned") + return True + + """ # Verify we reached the target final_displacement = self.measure_displacement() if math.isnan(final_displacement): self._log.error("Failed to verify final position") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) return False - - self._log.debug(f"Final displacement: {final_displacement:.1f} μm") - return abs(final_displacement - target_um) < 1.0 # Success if within 1 μm + if abs(final_displacement - target_um) > self.DISPLACEMENT_SUCCESS_WINDOW_UM: + self._log.warning(f"Final displacement ({final_displacement:.1f} μm) is out of the success window ({self.DISPLACEMENT_SUCCESS_WINDOW_UM:.1f} μm)") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) + return False + else: + self._log.info(f"Final displacement ({final_displacement:.1f} μm) is within the success window ({self.DISPLACEMENT_SUCCESS_WINDOW_UM:.1f} μm)") + return True + """ def set_reference(self) -> bool: """Set the current spot position as the reference position. + Captures and stores both the spot position and a cropped reference image + around the spot for later alignment verification. + Returns: bool: True if reference was set successfully, False if spot detection failed """ @@ -4706,23 +4732,82 @@ def set_reference(self) -> bool: self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # get laser spot location + # get laser spot location and image result = self._get_laser_spot_centroid() + reference_image = self.image # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() - if result is None: + if result is None or reference_image is None: self._log.error("Failed to detect laser spot while setting reference") return False x, y = result self.x_reference = x + + # Store cropped and normalized reference image + center_y = int(reference_image.shape[0] / 2) + x_start = max(0, int(x) - self.SPOT_CROP_SIZE // 2) + x_end = min(reference_image.shape[1], int(x) + self.SPOT_CROP_SIZE // 2) + y_start = max(0, center_y - self.SPOT_CROP_SIZE // 2) + y_end = min(reference_image.shape[0], center_y + self.SPOT_CROP_SIZE // 2) + + reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) + self.reference_crop = (reference_crop - np.mean(reference_crop)) / np.max(reference_crop) + self.signal_displacement_um.emit(0) self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") return True + def _verify_spot_alignment(self) -> bool: + """Verify laser spot alignment using cross-correlation with reference image. + + Captures current laser spot image and compares it with the reference image + using normalized cross-correlation. Images are cropped around the expected + spot location and normalized by maximum intensity before comparison. + + Returns: + bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise + """ + # Get current spot image + self.microcontroller.turn_on_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + current_image = self.camera.read_frame() + + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + if current_image is None or not hasattr(self, "reference_crop"): + self._log.error("Failed to get images for cross-correlation check") + return False + + # Crop and normalize current image + center_x = int(self.x_reference) + center_y = int(current_image.shape[0] / 2) + + x_start = max(0, center_x - self.SPOT_CROP_SIZE // 2) + x_end = min(current_image.shape[1], center_x + self.SPOT_CROP_SIZE // 2) + y_start = max(0, center_y - self.SPOT_CROP_SIZE // 2) + y_end = min(current_image.shape[0], center_y + self.SPOT_CROP_SIZE // 2) + + current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) + current_norm = (current_crop - np.mean(current_crop)) / np.max(current_crop) + + # Calculate normalized cross correlation + correlation = np.corrcoef(current_norm.ravel(), self.reference_crop.ravel())[0, 1] + + self._log.info(f"Cross correlation with reference: {correlation:.3f}") + + # Check if correlation exceeds threshold + if correlation < self.CORRELATION_THRESHOLD: + self._log.warning("Cross correlation check failed - spots not well aligned") + return False + + return True + def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: """Get the centroid location of the laser spot. @@ -4754,14 +4839,10 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: self._log.warning(f"Failed to read frame {i+1}/{LASER_AF_AVERAGING_N}") continue - self.image = image # store for debugging - - # optionally display the image - if LASER_AF_DISPLAY_SPOT_IMAGE: - self.image_to_display.emit(image) + self.image = image # store for debugging # TODO: add to return instead of storing # calculate centroid - result = utils.find_spot_location(image) + result = utils.find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) if result is None: self._log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") continue @@ -4775,6 +4856,10 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: self._log.error(f"Error processing frame {i+1}/{LASER_AF_AVERAGING_N}: {str(e)}") continue + # optionally display the image + if LASER_AF_DISPLAY_SPOT_IMAGE: + self.image_to_display.emit(image) + # Check if we got enough successful detections if successful_detections == 0: self._log.error(f"No successful detections") From 33acc244dce6767b6207e7ca8f94e667803b73e0 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 23:10:23 -0800 Subject: [PATCH 06/10] bug fix --- software/control/core/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index ac644699..faa366a5 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -4631,7 +4631,7 @@ def initialize_auto(self) -> bool: self.pixel_to_um = 0.4 # Simulation value self._log.warning("Using simulation value for pixel_to_um conversion") else: - self.pixel_to_um = PIXEL_TO_UM_CALIBRATION_DISTANCE / (x1 - x0) + self.pixel_to_um = self.PIXEL_TO_UM_CALIBRATION_DISTANCE / (x1 - x0) self._log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") # Set reference position From 1dbca048a190fd453d2d42c5ce53e861fd785f04 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 23:25:13 -0800 Subject: [PATCH 07/10] add laser af testing (including plotting) --- software/control/camera.py | 6 + .../control/tests/data/laser_af_camera.png | Bin 0 -> 59892 bytes .../tests/test_spot_detection_manual.py | 68 +++++++++++ software/control/tests/test_utils.py | 111 ++++++++++++++++++ software/control/utils.py | 75 ++++++++++-- 5 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 software/control/tests/data/laser_af_camera.png create mode 100644 software/control/tests/test_spot_detection_manual.py create mode 100644 software/control/tests/test_utils.py diff --git a/software/control/camera.py b/software/control/camera.py index a7448636..4108e668 100644 --- a/software/control/camera.py +++ b/software/control/camera.py @@ -601,6 +601,12 @@ def send_trigger(self): def read_frame(self): return self.current_frame + ''' + # read from disk for laser af debugging + image = cv2.imread("control/tests/data/laser_af_camera.png")[:, :, 0] + height, width = image.shape + return image + np.random.randint(0, 10, size=(height, width), dtype=np.uint8) + ''' def _on_frame_callback(self, user_param, raw_image): pass diff --git a/software/control/tests/data/laser_af_camera.png b/software/control/tests/data/laser_af_camera.png new file mode 100644 index 0000000000000000000000000000000000000000..0a2c1f7caec0b047d2568ac4d77a86cb28b66f78 GIT binary patch literal 59892 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 r~)#eN`oq*e|E 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 N002ovPDHLkV1gmS&^rJC literal 0 HcmV?d00001 diff --git a/software/control/tests/test_spot_detection_manual.py b/software/control/tests/test_spot_detection_manual.py new file mode 100644 index 00000000..dd751660 --- /dev/null +++ b/software/control/tests/test_spot_detection_manual.py @@ -0,0 +1,68 @@ +import cv2 +import numpy as np +import matplotlib.pyplot as plt +from ..utils import find_spot_location, SpotDetectionMode + + +def test_image_from_disk(image_path: str): + """Test spot detection on a real image file. + + Args: + image_path: Path to the image file to test + """ + # Load image + image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if image is None: + raise ValueError(f"Failed to load image from {image_path}") + + print(f"Loaded image shape: {image.shape}") + + # Try different detection modes + modes = [SpotDetectionMode.SINGLE, SpotDetectionMode.DUAL_LEFT, SpotDetectionMode.DUAL_RIGHT] + + # Test parameters to try + param_sets = [ + { + "y_window": 96, + "x_window": 20, + "min_peak_width": 10, + "min_peak_distance": 10, + "min_peak_prominence": 0.25, + }, + { + "y_window": 96, + "x_window": 20, + "min_peak_width": 5, + "min_peak_distance": 20, + "min_peak_prominence": 0.25, + }, + ] + + # Create figure for results + plt.figure(figsize=(15, 10)) + + # Try each mode and parameter set + for i, mode in enumerate(modes): + print(f"\nTesting {mode.name}:") + + for j, params in enumerate(param_sets): + print(f"\nParameters set {j+1}:") + print(params) + + result = find_spot_location(image, mode=mode, params=params, debug_plot=True) + + if result is not None: + x, y = result + print(f"Found spot at: ({x:.1f}, {y:.1f})") + else: + print("No spot detected") + + # Wait for user to review plots + input("Press Enter to continue to next test...") + plt.close("all") + + +if __name__ == "__main__": + # Replace with path to your test image + image_path = "control/tests/data/laser_af_camera.png" + test_image_from_disk(image_path) diff --git a/software/control/tests/test_utils.py b/software/control/tests/test_utils.py new file mode 100644 index 00000000..241b7a97 --- /dev/null +++ b/software/control/tests/test_utils.py @@ -0,0 +1,111 @@ +import numpy as np +import pytest +from ..utils import find_spot_location, SpotDetectionMode + + +def create_test_image(spot_positions, image_size=(480, 640), spot_size=20): + """Create a test image with Gaussian spots at specified positions. + + Args: + spot_positions: List of (x, y) coordinates for spot centers + image_size: Tuple of (height, width) for the image + spot_size: Approximate diameter of each spot + """ + image = np.zeros(image_size) + y, x = np.ogrid[: image_size[0], : image_size[1]] + + for pos_x, pos_y in spot_positions: + spot = np.exp(-((x - pos_x) ** 2 + (y - pos_y) ** 2) / (2 * (spot_size / 4) ** 2)) + image += spot + + # Normalize and convert to uint8 + image = (image * 255 / np.max(image)).astype(np.uint8) + return image + + +def test_single_spot_detection(): + # Create test image with single spot + spot_x, spot_y = 320, 240 + image = create_test_image([(spot_x, spot_y)]) + + # Test detection + result = find_spot_location(image, mode=SpotDetectionMode.SINGLE) + + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spot_x) < 5 + assert abs(detected_y - spot_y) < 5 + + +def test_dual_spot_detection(): + # Create test image with two spots + spots = [(280, 240), (360, 240)] + image = create_test_image(spots) + + # Test right spot detection + result = find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[1][0]) < 5 + + # Test left spot detection + result = find_spot_location(image, mode=SpotDetectionMode.DUAL_LEFT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[0][0]) < 5 + + +def test_multi_spot_detection(): + # Create test image with multiple spots + spots = [(200, 240), (280, 240), (360, 240)] + image = create_test_image(spots) + + # Test rightmost spot detection + result = find_spot_location(image, mode=SpotDetectionMode.MULTI_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[2][0]) < 5 + + # Test second from right spot detection + result = find_spot_location(image, mode=SpotDetectionMode.MULTI_SECOND_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[1][0]) < 5 + + +def test_invalid_inputs(): + # Test empty image + with pytest.raises(ValueError): + find_spot_location(np.zeros((480, 640), dtype=np.uint8)) + + # Test None image + with pytest.raises(ValueError): + find_spot_location(None) + + # Test invalid mode + with pytest.raises(ValueError): + find_spot_location(np.zeros((480, 640), dtype=np.uint8), mode="invalid") + + +def test_spot_detection_parameters(): + # Create test image with single spot + image = create_test_image([(320, 240)]) + + # Test with custom parameters + params = { + "y_window": 50, + "x_window": 15, + "min_peak_width": 5, + "min_peak_distance": 5, + "min_peak_prominence": 0.25, + } + + result = find_spot_location(image, params=params) + assert result is not None + + +def test_debug_plot(tmp_path): + """Test that debug plotting doesn't error.""" + image = create_test_image([(320, 240)]) + # This should create plots but not raise any errors + find_spot_location(image, debug_plot=True) diff --git a/software/control/utils.py b/software/control/utils.py index ea981442..38c15718 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -171,7 +171,10 @@ def ensure_directory_exists(raw_string_path: str): def find_spot_location( - image: np.ndarray, mode: SpotDetectionMode = SpotDetectionMode.SINGLE, params: Optional[dict] = None + image: np.ndarray, + mode: SpotDetectionMode = SpotDetectionMode.SINGLE, + params: Optional[dict] = None, + debug_plot: bool = False, ) -> Optional[Tuple[float, float]]: """Find the location of a spot in an image. @@ -204,7 +207,7 @@ def find_spot_location( "x_window": 20, # Half-width of centroid window "min_peak_width": 10, # Minimum width of peaks "min_peak_distance": 10, # Minimum distance between peaks - "min_peak_prominence": 100, # Minimum peak prominence + "min_peak_prominence": 0.25, # Minimum peak prominence "intensity_threshold": 0.1, # Threshold for intensity filtering "spot_spacing": 100, # Expected spacing between spots } @@ -215,11 +218,11 @@ def find_spot_location( try: # Get the y position of the spots - intensity_profile = np.sum(image, axis=1) - if np.all(intensity_profile == 0): + y_intensity_profile = np.sum(image, axis=1) + if np.all(y_intensity_profile == 0): raise ValueError("No spots detected in image") - peak_y = np.argmax(intensity_profile) + peak_y = np.argmax(y_intensity_profile) # Validate peak_y location if peak_y < p["y_window"] or peak_y > image.shape[0] - p["y_window"]: @@ -229,16 +232,22 @@ def find_spot_location( cropped_image = image[peak_y - p["y_window"] : peak_y + p["y_window"], :] # Get signal along x - intensity_profile = np.sum(cropped_image, axis=0) + x_intensity_profile = np.sum(cropped_image, axis=0) + + # Normalize intensity profile + x_intensity_profile = x_intensity_profile - np.min(x_intensity_profile) + x_intensity_profile = x_intensity_profile / np.max(x_intensity_profile) # Find all peaks peaks = signal.find_peaks( - intensity_profile, + x_intensity_profile, width=p["min_peak_width"], distance=p["min_peak_distance"], prominence=p["min_peak_prominence"], ) peak_locations = peaks[0] + peak_properties = peaks[1] + if len(peak_locations) == 0: raise ValueError("No peaks detected") @@ -247,23 +256,67 @@ def find_spot_location( if len(peak_locations) > 1: raise ValueError(f"Found {len(peak_locations)} peaks but expected single peak") peak_x = peak_locations[0] - elif mode == SpotDetectionMode.DUAL_RIGHT: peak_x = peak_locations[-1] - elif mode == SpotDetectionMode.DUAL_LEFT: peak_x = peak_locations[0] - elif mode == SpotDetectionMode.MULTI_RIGHT: peak_x = peak_locations[-1] - elif mode == SpotDetectionMode.MULTI_SECOND_RIGHT: + """ + if len(peak_locations) < 2: + raise ValueError("Not enough peaks for MULTI_SECOND_RIGHT mode") peak_x = peak_locations[-2] + """ peak_x = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) peak_x = peak_x - p["spot_spacing"] else: raise ValueError(f"Unknown spot detection mode: {mode}") + if debug_plot: + import matplotlib.pyplot as plt + + fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 8)) + + # Plot original image + ax1.imshow(image, cmap="gray") + ax1.axhline(y=peak_y, color="r", linestyle="--", label="Peak Y") + ax1.axhline(y=peak_y - p["y_window"], color="g", linestyle="--", label="Crop Window") + ax1.axhline(y=peak_y + p["y_window"], color="g", linestyle="--") + ax1.legend() + ax1.set_title("Original Image with Y-crop Lines") + + # Plot Y intensity profile + ax2.plot(y_intensity_profile) + ax2.axvline(x=peak_y, color="r", linestyle="--", label="Peak Y") + ax2.axvline(x=peak_y - p["y_window"], color="g", linestyle="--", label="Crop Window") + ax2.axvline(x=peak_y + p["y_window"], color="g", linestyle="--") + ax2.legend() + ax2.set_title("Y Intensity Profile") + + # Plot X intensity profile and detected peaks + ax3.plot(x_intensity_profile, label="Intensity Profile") + ax3.plot(peak_locations, x_intensity_profile[peak_locations], "x", color="r", label="All Peaks") + + # Plot prominence for all peaks + for peak_idx, prominence in zip(peak_locations, peak_properties["prominences"]): + ax3.vlines( + x=peak_idx, + ymin=x_intensity_profile[peak_idx] - prominence, + ymax=x_intensity_profile[peak_idx], + color="g", + ) + + # Highlight selected peak + ax3.plot(peak_x, x_intensity_profile[peak_x], "o", color="yellow", markersize=10, label="Selected Peak") + ax3.axvline(x=peak_x, color="yellow", linestyle="--", alpha=0.5) + + ax3.legend() + ax3.set_title(f"X Intensity Profile (Mode: {mode.name})") + + plt.tight_layout() + plt.show() + # Calculate centroid in window around selected peak return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) From efc4acfc278b2759080877f9cbb3432846ab5d83 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 23:30:09 -0800 Subject: [PATCH 08/10] be more defensive by changing == 0 to <= 0 --- software/control/core/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/core/core.py b/software/control/core/core.py index faa366a5..d6d8300f 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -4861,7 +4861,7 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: self.image_to_display.emit(image) # Check if we got enough successful detections - if successful_detections == 0: + if successful_detections <= 0: self._log.error(f"No successful detections") return None From f7c835271bded869cfa2966e322f03ea775f89ac Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 23:46:30 -0800 Subject: [PATCH 09/10] remove obsolete variables --- .../configurations/configuration_HCS_v2.ini | 8 ++---- .../configurations/configuration_Squid+.ini | 8 ++---- .../configuration_Squid+_H117_ORCA.ini | 8 ++---- software/control/_def.py | 5 ++-- software/control/core/core.py | 6 +---- software/control/gui_hcs.py | 26 ++++++++++++------- 6 files changed, 25 insertions(+), 36 deletions(-) diff --git a/software/configurations/configuration_HCS_v2.ini b/software/configurations/configuration_HCS_v2.ini index 0ac51cde..39df1463 100644 --- a/software/configurations/configuration_HCS_v2.ini +++ b/software/configurations/configuration_HCS_v2.ini @@ -155,12 +155,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = MER2-1220-32U3M focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/configurations/configuration_Squid+.ini b/software/configurations/configuration_Squid+.ini index d63846ae..71c7c787 100644 --- a/software/configurations/configuration_Squid+.ini +++ b/software/configurations/configuration_Squid+.ini @@ -153,12 +153,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = MER2-1220-32U3M focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/configurations/configuration_Squid+_H117_ORCA.ini b/software/configurations/configuration_Squid+_H117_ORCA.ini index b136bbbc..3cef4a99 100644 --- a/software/configurations/configuration_Squid+_H117_ORCA.ini +++ b/software/configurations/configuration_Squid+_H117_ORCA.ini @@ -165,12 +165,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = C15440-20UP focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/control/_def.py b/software/control/_def.py index 893d2775..b84d6a4d 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -542,13 +542,12 @@ class SOFTWARE_POS_LIMIT: FOCUS_CAMERA_MODEL = "MER2-630-60U3M" FOCUS_CAMERA_EXPOSURE_TIME_MS = 2 FOCUS_CAMERA_ANALOG_GAIN = 0 -LASER_AF_AVERAGING_N = 5 +LASER_AF_AVERAGING_N = 3 LASER_AF_DISPLAY_SPOT_IMAGE = True LASER_AF_CROP_WIDTH = 1536 LASER_AF_CROP_HEIGHT = 256 -HAS_TWO_INTERFACES = True +LASER_AF_SPOT_DETECTION_MODE = SpotDetectionMode.DUAL_LEFT LASER_AF_RANGE = 200 -USE_GLASS_TOP = True SHOW_LEGACY_DISPLACEMENT_MEASUREMENT_WINDOWS = False MULTIPOINT_REFLECTION_AUTOFOCUS_ENABLE_BY_DEFAULT = False diff --git a/software/control/core/core.py b/software/control/core/core.py index d6d8300f..f7862fd3 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -4468,8 +4468,6 @@ def __init__( camera, liveController, stage: AbstractStage, - has_two_interfaces=True, - use_glass_top=True, look_for_cache=True, ): QObject.__init__(self) @@ -4487,8 +4485,6 @@ def __init__( self.x_width = 3088 self.y_width = 2064 - self.has_two_interfaces = has_two_interfaces # e.g. air-glass and glass water, set to false when (1) using oil immersion (2) using 1 mm thick slide (3) using metal coated slide or Si wafer - self.use_glass_top = use_glass_top self.spot_spacing_pixels = None # spacing between the spots from the two interfaces (unit: pixel) self.look_for_cache = look_for_cache @@ -4842,7 +4838,7 @@ def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: self.image = image # store for debugging # TODO: add to return instead of storing # calculate centroid - result = utils.find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) + result = utils.find_spot_location(image, mode=SPOT_DETECTION_MODE) if result is None: self._log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") continue diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 43924a97..81ddff8c 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -83,7 +83,10 @@ import control.camera as camera_fc if USE_XERYON: - from control.objective_changer_2_pos_controller import ObjectiveChanger2PosController, ObjectiveChanger2PosController_Simulation + from control.objective_changer_2_pos_controller import ( + ObjectiveChanger2PosController, + ObjectiveChanger2PosController_Simulation, + ) import control.core.core as core import control.microcontroller as microcontroller @@ -190,9 +193,9 @@ def __init__(self, is_simulation=False, live_only_mode=False, *args, **kwargs): if USE_JUPYTER_CONSOLE: # Create namespace to expose to Jupyter self.namespace = { - 'microscope': self.microscope, + "microscope": self.microscope, } - + # Create Jupyter widget as a dock widget self.jupyter_dock = QDockWidget("Jupyter Console", self) self.jupyter_widget = JupyterWidget(namespace=self.namespace) @@ -293,8 +296,6 @@ def loadObjects(self, is_simulation): self.camera_focus, self.liveController_focus_camera, self.stage, - has_two_interfaces=HAS_TWO_INTERFACES, - use_glass_top=USE_GLASS_TOP, look_for_cache=False, ) @@ -307,7 +308,9 @@ def loadSimulationObjects(self): serial_device=microcontroller.get_microcontroller_serial_device(simulated=True) ) if USE_PRIOR_STAGE: - self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage(sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config()) + self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage( + sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config() + ) else: self.stage: squid.abc.AbstractStage = squid.stage.cephla.CephlaStage( @@ -340,8 +343,9 @@ def loadSimulationObjects(self): if USE_SQUID_FILTERWHEEL: self.squid_filter_wheel = filterwheel.SquidFilterWheelWrapper_Simulation(None) if USE_XERYON: - self.objective_changer = ObjectiveChanger2PosController_Simulation(sn=XERYON_SERIAL_NUMBER,stage=self.stage) - + self.objective_changer = ObjectiveChanger2PosController_Simulation( + sn=XERYON_SERIAL_NUMBER, stage=self.stage + ) def loadHardwareObjects(self): # Initialize hardware objects @@ -356,7 +360,9 @@ def loadHardwareObjects(self): raise if USE_PRIOR_STAGE: - self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage(sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config()) + self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage( + sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config() + ) else: self.stage: squid.abc.AbstractStage = squid.stage.cephla.CephlaStage( @@ -452,7 +458,7 @@ def loadHardwareObjects(self): if USE_XERYON: try: - self.objective_changer = ObjectiveChanger2PosController(sn=XERYON_SERIAL_NUMBER,stage=self.stage) + self.objective_changer = ObjectiveChanger2PosController(sn=XERYON_SERIAL_NUMBER, stage=self.stage) except Exception: self.log.error("Error initializing Xeryon objective switcher") raise From da01667ac077064f47e7391d214c83676af65646 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Fri, 14 Feb 2025 23:59:46 -0800 Subject: [PATCH 10/10] move SpotDetectionMode back to utils.py so that utils.py does not depend on _def.py --- software/control/_def.py | 25 ++++--------------------- software/control/utils.py | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index b84d6a4d..e119ded3 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -5,8 +5,7 @@ from configparser import ConfigParser import json import csv -from enum import Enum, auto - +from control.utils import SpotDetectionMode import squid.logging log = squid.logging.get_logger(__name__) @@ -241,22 +240,6 @@ class CAMERA_CONFIG: ROI_WIDTH_DEFAULT = 3104 ROI_HEIGHT_DEFAULT = 2084 -class SpotDetectionMode(Enum): - """Specifies which spot to detect when multiple spots are present. - - SINGLE: Expect and detect single spot - DUAL_RIGHT: In dual-spot case, use rightmost spot - DUAL_LEFT: In dual-spot case, use leftmost spot - MULTI_RIGHT: In multi-spot case, use rightmost spot - MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot - """ - - SINGLE = auto() - DUAL_RIGHT = auto() - DUAL_LEFT = auto() - MULTI_RIGHT = auto() - MULTI_SECOND_RIGHT = auto() - PRINT_CAMERA_FPS = True @@ -747,10 +730,10 @@ def load_formats(): DEFAULT_DISPLAY_CROP = Tracking.DEFAULT_DISPLAY_CROP USE_XERYON = False -XERYON_SERIAL_NUMBER = '95130303033351E02050' +XERYON_SERIAL_NUMBER = "95130303033351E02050" XERYON_SPEED = 80 -XERYON_OBJECTIVE_SWITCHER_POS_1 = ['4x', '10x'] -XERYON_OBJECTIVE_SWITCHER_POS_2 = ['20x', '40x', '60x'] +XERYON_OBJECTIVE_SWITCHER_POS_1 = ["4x", "10x"] +XERYON_OBJECTIVE_SWITCHER_POS_2 = ["20x", "40x", "60x"] XERYON_OBJECTIVE_SWITCHER_POS_2_OFFSET_MM = 2 ########################################################## diff --git a/software/control/utils.py b/software/control/utils.py index 38c15718..df226c87 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -7,8 +7,7 @@ from scipy import signal import os from typing import Optional, Tuple -from ._def import SpotDetectionMode - +from enum import Enum, auto import squid.logging _log = squid.logging.get_logger("control.utils") @@ -170,6 +169,23 @@ def ensure_directory_exists(raw_string_path: str): path.mkdir(parents=True, exist_ok=True) +class SpotDetectionMode(Enum): + """Specifies which spot to detect when multiple spots are present. + + SINGLE: Expect and detect single spot + DUAL_RIGHT: In dual-spot case, use rightmost spot + DUAL_LEFT: In dual-spot case, use leftmost spot + MULTI_RIGHT: In multi-spot case, use rightmost spot + MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot + """ + + SINGLE = auto() + DUAL_RIGHT = auto() + DUAL_LEFT = auto() + MULTI_RIGHT = auto() + MULTI_SECOND_RIGHT = auto() + + def find_spot_location( image: np.ndarray, mode: SpotDetectionMode = SpotDetectionMode.SINGLE,