From e05aab90a8ee28c667f8f8e8a3d0c6855fbc4439 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 25 Nov 2025 21:18:19 -0800 Subject: [PATCH 01/26] search laser spot --- .../core/laser_auto_focus_controller.py | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index caeecdba5..1d1e9fb28 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -268,7 +268,7 @@ def update_threshold_properties(self, updates: dict) -> None: self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) self._log.info("Updated threshold properties") - def measure_displacement(self) -> float: + def measure_displacement(self, search_for_spot: bool = True) -> float: """Measure the displacement of the laser spot from the reference position. Returns: @@ -301,7 +301,74 @@ def finish_with(um: float) -> float: if result is None: self._log.error("Failed to detect laser spot during displacement measurement") - return finish_with(float("nan")) # Signal invalid measurement + if search_for_spot: + # Search for spot by scanning through z range, centered at current position + search_step_um = 10 # Step size in micrometers + + # Get current z position in um + current_z_um = self.stage.get_pos().z_mm * 1000 + + # Calculate absolute search bounds + lower_bound_um = current_z_um - self.laser_af_properties.laser_af_range + upper_bound_um = current_z_um + self.laser_af_properties.laser_af_range + + # Find first search position (round up to next multiple of step) + first_pos_um = math.ceil(lower_bound_um / search_step_um) * search_step_um + + # Generate list of search positions (aligned to step size) + search_positions_um = [] + pos = first_pos_um + while pos <= upper_bound_um: + search_positions_um.append(pos) + pos += search_step_um + + self._log.info(f"Starting spot search: positions {search_positions_um} um") + + spot_found = False + current_pos_um = current_z_um # Track where we are + + # turn on the laser + try: + self.microcontroller.turn_on_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + except TimeoutError: + self._log.exception("Turning on AF laser timed out, failed to measure displacement.") + return finish_with(float("nan")) + + for target_pos_um in search_positions_um: + # Move to target position + move_um = target_pos_um - current_pos_um + if move_um != 0: + self._move_z(move_um) + current_pos_um = target_pos_um + + # Get one image and attempt spot detection + result = self._get_laser_spot_centroid() + + if result is not None: + self._log.info(f"Spot found at z position {target_pos_um} um") + spot_found = True + break + + if not spot_found: + # Move back to original position + move_back_um = current_z_um - current_pos_um + if move_back_um != 0: + self._move_z(move_back_um) + self._log.warning("Spot not found during z search") + + # Turn off laser + try: + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + except TimeoutError: + self._log.exception("Failed to turn off AF laser after spot search") + + if not spot_found: + return finish_with(float("nan")) + + else: + return finish_with(float("nan")) x, y = result # calculate displacement From ae4c663336de171086c5b87ddc0e0b16a591b4d5 Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 6 Jan 2026 20:30:49 -0800 Subject: [PATCH 02/26] piezo --- software/control/_def.py | 4 + .../core/laser_auto_focus_controller.py | 192 ++++++++++-------- 2 files changed, 111 insertions(+), 85 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 95c986121..b6cce1137 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -614,6 +614,10 @@ class SOFTWARE_POS_LIMIT: LASER_AF_INITIALIZE_CROP_WIDTH = 1200 LASER_AF_INITIALIZE_CROP_HEIGHT = 800 +LASER_AF_SEARCH_DOWN_FIRST = ( + True # If True, search downward (smaller z values) first then upward; if False, search upward first +) + MULTIPOINT_REFLECTION_AUTOFOCUS_ENABLE_BY_DEFAULT = False MULTIPOINT_CONTRAST_AUTOFOCUS_ENABLE_BY_DEFAULT = False diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 1d1e9fb28..1497d2167 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -268,6 +268,21 @@ def update_threshold_properties(self, updates: dict) -> None: self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) self._log.info("Updated threshold properties") + def _turn_on_laser(self) -> None: + """Turn on AF laser. Raises TimeoutError on failure.""" + self.microcontroller.turn_on_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + def _turn_off_laser(self) -> None: + """Turn off AF laser. Raises TimeoutError on failure.""" + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + def _get_displacement_from_centroid(self, centroid: tuple) -> float: + """Calculate displacement in um from centroid coordinates.""" + x, y = centroid + return (x - self.laser_af_properties.x_reference) * self.laser_af_properties.pixel_to_um + def measure_displacement(self, search_for_spot: bool = True) -> float: """Measure the displacement of the laser spot from the reference position. @@ -280,9 +295,7 @@ def finish_with(um: float) -> float: return um try: - # turn on the laser - self.microcontroller.turn_on_AF_laser() - self.microcontroller.wait_till_operation_is_completed() + self._turn_on_laser() except TimeoutError: self._log.exception("Turning on AF laser timed out, failed to measure displacement.") return finish_with(float("nan")) @@ -290,90 +303,99 @@ def finish_with(um: float) -> float: # get laser spot location result = self._get_laser_spot_centroid() - # turn off the laser - try: - self.microcontroller.turn_off_AF_laser() - self.microcontroller.wait_till_operation_is_completed() - except TimeoutError: - self._log.exception("Turning off AF laser timed out! We got a displacement but laser may still be on.") - # Continue with the measurement, but we're essentially in an unknown / weird state here. It's not clear - # what we should do. + if result is not None: + # Spot found on first try + try: + self._turn_off_laser() + except TimeoutError: + self._log.exception("Turning off AF laser timed out! Laser may still be on.") + return finish_with(self._get_displacement_from_centroid(result)) - if result is None: - self._log.error("Failed to detect laser spot during displacement measurement") - if search_for_spot: - # Search for spot by scanning through z range, centered at current position - search_step_um = 10 # Step size in micrometers - - # Get current z position in um - current_z_um = self.stage.get_pos().z_mm * 1000 - - # Calculate absolute search bounds - lower_bound_um = current_z_um - self.laser_af_properties.laser_af_range - upper_bound_um = current_z_um + self.laser_af_properties.laser_af_range - - # Find first search position (round up to next multiple of step) - first_pos_um = math.ceil(lower_bound_um / search_step_um) * search_step_um - - # Generate list of search positions (aligned to step size) - search_positions_um = [] - pos = first_pos_um - while pos <= upper_bound_um: - search_positions_um.append(pos) - pos += search_step_um - - self._log.info(f"Starting spot search: positions {search_positions_um} um") - - spot_found = False - current_pos_um = current_z_um # Track where we are - - # turn on the laser - try: - self.microcontroller.turn_on_AF_laser() - self.microcontroller.wait_till_operation_is_completed() - except TimeoutError: - self._log.exception("Turning on AF laser timed out, failed to measure displacement.") - return finish_with(float("nan")) - - for target_pos_um in search_positions_um: - # Move to target position - move_um = target_pos_um - current_pos_um - if move_um != 0: - self._move_z(move_um) - current_pos_um = target_pos_um - - # Get one image and attempt spot detection - result = self._get_laser_spot_centroid() - - if result is not None: - self._log.info(f"Spot found at z position {target_pos_um} um") - spot_found = True - break - - if not spot_found: - # Move back to original position - move_back_um = current_z_um - current_pos_um - if move_back_um != 0: - self._move_z(move_back_um) - self._log.warning("Spot not found during z search") - - # Turn off laser - try: - self.microcontroller.turn_off_AF_laser() - self.microcontroller.wait_till_operation_is_completed() - except TimeoutError: - self._log.exception("Failed to turn off AF laser after spot search") - - if not spot_found: - return finish_with(float("nan")) - - else: - return finish_with(float("nan")) + self._log.error("Failed to detect laser spot during displacement measurement") - x, y = result - # calculate displacement - displacement_um = (x - self.laser_af_properties.x_reference) * self.laser_af_properties.pixel_to_um - return finish_with(displacement_um) + if not search_for_spot: + try: + self._turn_off_laser() + except TimeoutError: + self._log.exception("Turning off AF laser timed out! Laser may still be on.") + return finish_with(float("nan")) + + # Search for spot by scanning through z range (laser stays on during search) + search_step_um = 10 # Step size in micrometers + + # Get current z position in um (piezo or stage) + if self.piezo is not None: + current_z_um = self.piezo.position + # For piezo, clamp bounds to valid piezo range (0 to range_um) + lower_bound_um = max(0, current_z_um - self.laser_af_properties.laser_af_range) + upper_bound_um = min(self.piezo.range_um, current_z_um + self.laser_af_properties.laser_af_range) + else: + current_z_um = self.stage.get_pos().z_mm * 1000 + lower_bound_um = current_z_um - self.laser_af_properties.laser_af_range + upper_bound_um = current_z_um + self.laser_af_properties.laser_af_range + + # Generate positions going downward (from current to lower_bound) + downward_positions = [] + pos = current_z_um - search_step_um + while pos >= lower_bound_um: + downward_positions.append(pos) + pos -= search_step_um + + # Generate positions going upward (from current to upper_bound) + upward_positions = [] + pos = current_z_um + search_step_um + while pos <= upper_bound_um: + upward_positions.append(pos) + pos += search_step_um + + # Order positions based on search direction preference + if control._def.LASER_AF_SEARCH_DOWN_FIRST: + # Search downward first, then upward + search_positions_um = downward_positions + [current_z_um] + upward_positions + else: + # Search upward first, then downward + search_positions_um = upward_positions + [current_z_um] + downward_positions + + self._log.info( + f"Starting spot search ({'downward' if control._def.LASER_AF_SEARCH_DOWN_FIRST else 'upward'} first): " + f"positions {search_positions_um} um" + ) + + current_pos_um = current_z_um # Track where we are + + for target_pos_um in search_positions_um: + # Move to target position + move_um = target_pos_um - current_pos_um + if move_um != 0: + self._move_z(move_um) + current_pos_um = target_pos_um + + # Attempt spot detection + result = self._get_laser_spot_centroid() + + if result is not None: + displacement_um = self._get_displacement_from_centroid(result) + if abs(displacement_um) <= search_step_um + 4: + self._log.info( + f"Spot found at z position {target_pos_um} um, displacement {displacement_um:.1f} um" + ) + try: + self._turn_off_laser() + except TimeoutError: + self._log.exception("Turning off AF laser timed out! Laser may still be on.") + return finish_with(displacement_um) + + # Spot not found - move back to original position + move_back_um = current_z_um - current_pos_um + if move_back_um != 0: + self._move_z(move_back_um) + self._log.warning("Spot not found during z search") + + try: + self._turn_off_laser() + except TimeoutError: + self._log.exception("Turning off AF laser timed out! Laser may still be on.") + return finish_with(float("nan")) def move_to_target(self, target_um: float) -> bool: """Move the stage to reach a target displacement from reference position. From 8e87c5516871036e40c79c17e328f029bc36737d Mon Sep 17 00:00:00 2001 From: You Yan Date: Wed, 7 Jan 2026 23:04:31 -0800 Subject: [PATCH 03/26] intensity profile cross correlation check --- software/control/_def.py | 2 + .../control/core/laser_af_settings_manager.py | 8 +- .../core/laser_auto_focus_controller.py | 105 ++++++++++++++++-- software/control/utils.py | 26 ++++- software/control/utils_config.py | 19 ++++ 5 files changed, 146 insertions(+), 14 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index b6cce1137..942659a27 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -602,6 +602,8 @@ class SOFTWARE_POS_LIMIT: DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 SPOT_CROP_SIZE = 100 CORRELATION_THRESHOLD = 0.7 +INTENSITY_PROFILE_CORRELATION_THRESHOLD = 0.6 # Threshold for intensity profile CC check +INTENSITY_PROFILE_HALF_WIDTH = 200 # Half-width of centered intensity profile (total length = 400) PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 LASER_AF_Y_WINDOW = 96 LASER_AF_X_WINDOW = 20 diff --git a/software/control/core/laser_af_settings_manager.py b/software/control/core/laser_af_settings_manager.py index 558c3fcbe..311eb15b3 100644 --- a/software/control/core/laser_af_settings_manager.py +++ b/software/control/core/laser_af_settings_manager.py @@ -48,7 +48,11 @@ def get_laser_af_settings(self) -> Dict[str, Any]: return self.autofocus_configurations def update_laser_af_settings( - self, objective: str, updates: Dict[str, Any], crop_image: Optional[np.ndarray] = None + self, + objective: str, + updates: Dict[str, Any], + crop_image: Optional[np.ndarray] = None, + intensity_profile: Optional[np.ndarray] = None, ) -> None: if objective not in self.autofocus_configurations: self.autofocus_configurations[objective] = LaserAFConfig(**updates) @@ -57,3 +61,5 @@ def update_laser_af_settings( self.autofocus_configurations[objective] = config.model_copy(update=updates) if crop_image is not None: self.autofocus_configurations[objective].set_reference_image(crop_image) + if intensity_profile is not None: + self.autofocus_configurations[objective].set_reference_intensity_profile(intensity_profile) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 1497d2167..570d8047b 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -51,10 +51,12 @@ def __init__( self.laser_af_properties = LaserAFConfig() self.reference_crop = None + self.reference_intensity_profile = None # reference intensity profile for CC check self.spot_spacing_pixels = None # spacing between the spots from the two interfaces (unit: pixel) self.image = None # for saving the focus camera image for debugging when centroid cannot be found + self.intensity_profile = None # temporary storage for intensity profile during set_reference # Load configurations if provided if self.laserAFSettingManager: @@ -77,6 +79,7 @@ def initialize_manual(self, config: LaserAFConfig) -> None: if self.laser_af_properties.has_reference: self.reference_crop = self.laser_af_properties.reference_image_cropped + self.reference_intensity_profile = self.laser_af_properties.reference_intensity_profile_array self.camera.set_region_of_interest( self.laser_af_properties.x_offset, @@ -301,7 +304,7 @@ def finish_with(um: float) -> float: return finish_with(float("nan")) # get laser spot location - result = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid(check_intensity_correlation=True) if result is not None: # Spot found on first try @@ -371,7 +374,7 @@ def finish_with(um: float) -> float: current_pos_um = target_pos_um # Attempt spot detection - result = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid(check_intensity_correlation=True) if result is not None: displacement_um = self._get_displacement_from_centroid(result) @@ -470,6 +473,7 @@ def set_reference(self) -> bool: # get laser spot location and image result = self._get_laser_spot_centroid() reference_image = self.image + reference_intensity_profile = self.intensity_profile # turn off the laser try: @@ -483,17 +487,28 @@ def set_reference(self) -> bool: self._log.error("Failed to detect laser spot while setting reference") return False + if reference_intensity_profile is None: + self._log.warning("No intensity profile available for reference") + # Continue anyway - intensity profile is optional for backward compatibility + x, y = result # Store cropped and normalized reference image center_y = int(reference_image.shape[0] / 2) x_start = max(0, int(x) - self.laser_af_properties.spot_crop_size // 2) - x_end = min(reference_image.shape[1], int(x) + self.laser_af_properties.spot_crop_size // 2) + x_end = min( + reference_image.shape[1], + int(x) + self.laser_af_properties.spot_crop_size // 2, + ) y_start = max(0, center_y - self.laser_af_properties.spot_crop_size // 2) - y_end = min(reference_image.shape[0], center_y + self.laser_af_properties.spot_crop_size // 2) + y_end = min( + reference_image.shape[0], + center_y + self.laser_af_properties.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.reference_intensity_profile = reference_intensity_profile self.signal_displacement_um.emit(0) self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") @@ -502,11 +517,15 @@ def set_reference(self) -> bool: update={"x_reference": x, "has_reference": True} ) # We don't keep reference_crop here to avoid serializing it - # Update cached file. reference_crop needs to be saved. + # Update cached file. reference_crop and intensity_profile need to be saved. self.laserAFSettingManager.update_laser_af_settings( self.objectiveStore.current_objective, - {"x_reference": x + self.laser_af_properties.x_offset, "has_reference": True}, + { + "x_reference": x + self.laser_af_properties.x_offset, + "has_reference": True, + }, crop_image=self.reference_crop, + intensity_profile=reference_intensity_profile, ) self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) @@ -590,19 +609,71 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: return True, correlation + def _check_intensity_profile_correlation( + self, intensity_profile: np.ndarray, debug_plot: bool = False + ) -> Tuple[bool, float]: + """Check if current intensity profile correlates well with reference. + + Args: + intensity_profile: Current intensity profile to compare + debug_plot: If True, show a matplotlib plot comparing the profiles + + Returns: + Tuple[bool, float]: (passed, correlation) where passed is True if correlation >= threshold + """ + if self.reference_intensity_profile is None: + self._log.warning("No reference intensity profile available for comparison") + return True, float("nan") # Pass if no reference (backward compatibility) + + # Calculate normalized cross-correlation + correlation = np.corrcoef(intensity_profile.ravel(), self.reference_intensity_profile.ravel())[0, 1] + self._log.debug(f"Intensity profile correlation: {correlation:.3f}") + + if debug_plot: + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(10, 4)) + x = np.arange(len(self.reference_intensity_profile)) + ax.plot(x, self.reference_intensity_profile, label="Reference", alpha=0.8) + ax.plot(x, intensity_profile, label="Current", alpha=0.8) + ax.axhline(y=0, color="gray", linestyle="--", alpha=0.3) + ax.set_xlabel("Position (pixels from center)") + ax.set_ylabel("Normalized Intensity") + ax.set_title(f"Intensity Profile Comparison (correlation: {correlation:.3f})") + ax.legend() + plt.tight_layout() + plt.show() + + passed = correlation >= control._def.INTENSITY_PROFILE_CORRELATION_THRESHOLD + if not passed: + self._log.warning( + f"Intensity profile correlation check failed: {correlation:.3f} < " + f"{control._def.INTENSITY_PROFILE_CORRELATION_THRESHOLD}" + ) + + return passed, correlation + def get_new_frame(self): # IMPORTANT: This assumes that the autofocus laser is already on! self.camera.send_trigger(self.camera.get_exposure_time()) return self.camera.read_frame() def _get_laser_spot_centroid( - self, remove_background: bool = False, use_center_crop: Optional[Tuple[int, int]] = None + self, + remove_background: bool = False, + use_center_crop: Optional[Tuple[int, int]] = None, + check_intensity_correlation: bool = False, ) -> 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. + Args: + remove_background: Apply background removal using top-hat filter + use_center_crop: (width, height) to crop around center before detection + check_intensity_correlation: If True, verify intensity profile correlation with reference + Returns: Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails """ @@ -612,6 +683,7 @@ def _get_laser_spot_centroid( successful_detections = 0 tmp_x = 0 tmp_y = 0 + intensity_profile = None image = None for i in range(self.laser_af_properties.laser_af_averaging_n): @@ -653,13 +725,16 @@ def _get_laser_spot_centroid( ) continue + # Unpack result: (centroid_x, centroid_y, intensity_profile) + spot_x, spot_y, intensity_profile = result + if use_center_crop is not None: x, y = ( - result[0] + (full_width - use_center_crop[0]) // 2, - result[1] + (full_height - use_center_crop[1]) // 2, + spot_x + (full_width - use_center_crop[0]) // 2, + spot_y + (full_height - use_center_crop[1]) // 2, ) else: - x, y = result + x, y = spot_x, spot_y if ( self.laser_af_properties.has_reference @@ -690,6 +765,16 @@ def _get_laser_spot_centroid( self._log.error(f"No successful detections") return None + # Perform intensity profile cross-correlation check if requested + if check_intensity_correlation: + if intensity_profile is not None: + passed, correlation = self._check_intensity_profile_correlation(intensity_profile) + if not passed: + return None + else: + # Store the intensity profile for later use (e.g., when setting reference) + self.intensity_profile = intensity_profile + # Calculate average position from successful detections x = tmp_x / successful_detections y = tmp_y / successful_detections diff --git a/software/control/utils.py b/software/control/utils.py index 033102002..97267417b 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -25,6 +25,7 @@ LASER_AF_MIN_PEAK_DISTANCE, LASER_AF_MIN_PEAK_PROMINENCE, LASER_AF_SPOT_SPACING, + INTENSITY_PROFILE_HALF_WIDTH, SpotDetectionMode, FocusMeasureOperator, ) @@ -207,7 +208,7 @@ def find_spot_location( params: Optional[dict] = None, filter_sigma: Optional[int] = None, debug_plot: bool = False, -) -> Optional[Tuple[float, float]]: +) -> Optional[Tuple[float, float, np.ndarray]]: """Find the location of a spot in an image. Args: @@ -224,7 +225,9 @@ def find_spot_location( - 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 + Optional[Tuple[float, float, np.ndarray]]: (x, y, intensity_profile) where intensity_profile is a + 1D array of length 400 (INTENSITY_PROFILE_HALF_WIDTH * 2) centered at the detected peak, + or None if detection fails Raises: ValueError: If image is invalid or mode is incompatible with detected spots @@ -309,6 +312,22 @@ def find_spot_location( else: raise ValueError(f"Unknown spot detection mode: {mode}") + # Extract centered intensity profile (200 pixels on each side of peak) + profile_start = peak_x - INTENSITY_PROFILE_HALF_WIDTH + profile_end = peak_x + INTENSITY_PROFILE_HALF_WIDTH + profile_length = INTENSITY_PROFILE_HALF_WIDTH * 2 + + if profile_start < 0 or profile_end > len(x_intensity_profile): + # Handle edge cases with padding + centered_profile = np.zeros(profile_length, dtype=x_intensity_profile.dtype) + src_start = max(0, profile_start) + src_end = min(len(x_intensity_profile), profile_end) + dst_start = max(0, -profile_start) + dst_end = dst_start + (src_end - src_start) + centered_profile[dst_start:dst_end] = x_intensity_profile[src_start:src_end] + else: + centered_profile = x_intensity_profile[profile_start:profile_end].copy() + if debug_plot: import matplotlib.pyplot as plt @@ -354,7 +373,8 @@ def find_spot_location( plt.show() # Calculate centroid in window around selected peak - return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + centroid_x, centroid_y = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + return (centroid_x, centroid_y, centered_profile) except (ValueError, NotImplementedError) as e: raise e diff --git a/software/control/utils_config.py b/software/control/utils_config.py index f1dca9b5d..bef5cd69d 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -63,6 +63,8 @@ class LaserAFConfig(BaseModel): reference_image: Optional[str] = None # Stores base64 encoded reference image for cross-correlation check reference_image_shape: Optional[tuple] = None reference_image_dtype: Optional[str] = None + reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check + reference_intensity_profile_dtype: Optional[str] = None initialize_crop_width: int = 1200 # Width of the center crop used for initialization initialize_crop_height: int = 800 # Height of the center crop used for initialization @@ -74,6 +76,14 @@ def reference_image_cropped(self) -> Optional[np.ndarray]: data = base64.b64decode(self.reference_image.encode("utf-8")) return np.frombuffer(data, dtype=np.dtype(self.reference_image_dtype)).reshape(self.reference_image_shape) + @property + def reference_intensity_profile_array(self) -> Optional[np.ndarray]: + """Convert stored base64 intensity profile data back to numpy array""" + if self.reference_intensity_profile is None: + return None + data = base64.b64decode(self.reference_intensity_profile.encode("utf-8")) + return np.frombuffer(data, dtype=np.dtype(self.reference_intensity_profile_dtype)) + @field_validator("spot_detection_mode", mode="before") @classmethod def validate_spot_detection_mode(cls, v): @@ -95,6 +105,15 @@ def set_reference_image(self, image: Optional[np.ndarray]) -> None: self.reference_image_dtype = str(image.dtype) self.has_reference = True + def set_reference_intensity_profile(self, profile: Optional[np.ndarray]) -> None: + """Convert numpy array to base64 encoded string or clear if None""" + if profile is None: + self.reference_intensity_profile = None + self.reference_intensity_profile_dtype = None + return + self.reference_intensity_profile = base64.b64encode(profile.tobytes()).decode("utf-8") + self.reference_intensity_profile_dtype = str(profile.dtype) + def model_dump(self, serialize=False, **kwargs): """Ensure proper serialization of enums to strings""" data = super().model_dump(**kwargs) From 9714f28643e8c60ffe5811210afdb23c36bac4c3 Mon Sep 17 00:00:00 2001 From: You Yan Date: Wed, 7 Jan 2026 23:18:25 -0800 Subject: [PATCH 04/26] fix callers --- software/control/widgets.py | 2 +- software/tests/control/test_spot_detection_manual.py | 2 +- software/tests/control/test_utils.py | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 10f80cfc2..91644955c 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -766,7 +766,7 @@ def run_spot_detection(self): try: result = utils.find_spot_location(frame, mode=mode, params=params, filter_sigma=sigma, debug_plot=True) if result is not None: - x, y = result + x, y, _ = result # Unpack centroid (x, y) and ignore intensity_profile self.signal_laser_spot_location.emit(frame, x, y) else: raise Exception("No spot detection result returned") diff --git a/software/tests/control/test_spot_detection_manual.py b/software/tests/control/test_spot_detection_manual.py index 2d4650ab3..cdec7ca27 100644 --- a/software/tests/control/test_spot_detection_manual.py +++ b/software/tests/control/test_spot_detection_manual.py @@ -56,7 +56,7 @@ def check_image_from_disk(image_path: str): ) if result is not None: - x, y = result + x, y, _ = result print(f"Found spot at: ({x:.1f}, {y:.1f})") else: print("No spot detected") diff --git a/software/tests/control/test_utils.py b/software/tests/control/test_utils.py index 41d90236a..8300d34ce 100644 --- a/software/tests/control/test_utils.py +++ b/software/tests/control/test_utils.py @@ -45,9 +45,11 @@ def test_single_spot_detection(): result = find_spot_location(image, mode=SpotDetectionMode.SINGLE) assert result is not None - detected_x, detected_y = result + detected_x, detected_y, intensity_profile = result assert abs(detected_x - spot_x) < 5 assert abs(detected_y - spot_y) < 5 + assert intensity_profile is not None + assert len(intensity_profile) == 400 # INTENSITY_PROFILE_HALF_WIDTH * 2 def test_dual_spot_detection(): @@ -58,13 +60,13 @@ def test_dual_spot_detection(): # Test right spot detection result = find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) assert result is not None - detected_x, detected_y = result + 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 + detected_x, detected_y, _ = result assert abs(detected_x - spots[0][0]) < 5 @@ -76,7 +78,7 @@ def test_multi_spot_detection(): # Test rightmost spot detection result = find_spot_location(image, mode=SpotDetectionMode.MULTI_RIGHT) assert result - detected_x, detected_y = result + detected_x, detected_y, _ = result assert abs(detected_x - spots[2][0]) < 5 # Test second from right spot detection From 955a660b0f110703c7e3acdb8e46bbe4ff6d0387 Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 8 Jan 2026 18:39:23 -0800 Subject: [PATCH 05/26] rmse --- software/control/_def.py | 2 +- .../core/laser_auto_focus_controller.py | 81 ++++++++++++++++--- 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 942659a27..81a2e90dc 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -602,7 +602,7 @@ class SOFTWARE_POS_LIMIT: DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 SPOT_CROP_SIZE = 100 CORRELATION_THRESHOLD = 0.7 -INTENSITY_PROFILE_CORRELATION_THRESHOLD = 0.6 # Threshold for intensity profile CC check +INTENSITY_PROFILE_RMSE_THRESHOLD = 1.0 # Maximum RMSE score for intensity profile match (lower is better) INTENSITY_PROFILE_HALF_WIDTH = 200 # Half-width of centered intensity profile (total length = 400) PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 LASER_AF_Y_WINDOW = 96 diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 570d8047b..9efaebb63 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -81,6 +81,20 @@ def initialize_manual(self, config: LaserAFConfig) -> None: self.reference_crop = self.laser_af_properties.reference_image_cropped self.reference_intensity_profile = self.laser_af_properties.reference_intensity_profile_array + # Invalidate reference if either crop image or intensity profile is missing + if self.reference_crop is None or self.reference_intensity_profile is None: + missing = [] + if self.reference_crop is None: + missing.append("reference image") + if self.reference_intensity_profile is None: + missing.append("intensity profile") + self._log.warning( + f"Loaded laser AF profile is missing {', '.join(missing)}. " "Please re-set reference." + ) + self.laser_af_properties = self.laser_af_properties.model_copy(update={"has_reference": False}) + self.reference_crop = None + self.reference_intensity_profile = None + self.camera.set_region_of_interest( self.laser_af_properties.x_offset, self.laser_af_properties.y_offset, @@ -609,25 +623,66 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: return True, correlation - def _check_intensity_profile_correlation( + def _compute_intensity_profile_rmse( + self, y: np.ndarray, r: np.ndarray, edge_frac: float = 0.1, k: float = 3.0, lam: float = 0.5 + ) -> Tuple[float, dict]: + """Compute RMSE-based match score between two intensity profiles. + + Uses log-transformed RMSE with automatic delta estimation from edge noise, + plus optional derivative term for shape matching. + + Args: + y: Current intensity profile + r: Reference intensity profile + edge_frac: Fraction of edges to use for noise estimation + k: Multiplier for noise-based delta (typically 2-5) + lam: Weight for derivative term + + Returns: + Tuple[float, dict]: (score, details) where score is combined RMSE (lower is better) + """ + y = np.asarray(y, float) + r = np.asarray(r, float) + + # Estimate delta from measured profile edges + n = y.size + m = max(1, int(edge_frac * n)) + edges = np.r_[y[:m], y[-m:]] + sigma = float(np.std(edges)) + delta = max(1e-4, k * sigma) + + ly = np.log(y + delta) + lr = np.log(r + delta) + + err = float(np.sqrt(np.mean((ly - lr) ** 2))) + err_d = float(np.sqrt(np.mean((np.diff(ly) - np.diff(lr)) ** 2))) + score = err + lam * err_d + + return score, {"err": err, "err_d": err_d, "score": score, "delta": delta, "sigma_edge": sigma, "lam": lam} + + def _check_intensity_profile_match( self, intensity_profile: np.ndarray, debug_plot: bool = False ) -> Tuple[bool, float]: - """Check if current intensity profile correlates well with reference. + """Check if current intensity profile matches reference using RMSE score. Args: intensity_profile: Current intensity profile to compare debug_plot: If True, show a matplotlib plot comparing the profiles Returns: - Tuple[bool, float]: (passed, correlation) where passed is True if correlation >= threshold + Tuple[bool, float]: (passed, score) where passed is True if score <= threshold """ if self.reference_intensity_profile is None: self._log.warning("No reference intensity profile available for comparison") return True, float("nan") # Pass if no reference (backward compatibility) - # Calculate normalized cross-correlation - correlation = np.corrcoef(intensity_profile.ravel(), self.reference_intensity_profile.ravel())[0, 1] - self._log.debug(f"Intensity profile correlation: {correlation:.3f}") + # Calculate RMSE-based match score + score, details = self._compute_intensity_profile_rmse( + intensity_profile.ravel(), self.reference_intensity_profile.ravel() + ) + self._log.debug( + f"Intensity profile RMSE score: {score:.3f} (err={details['err']:.3f}, err_d={details['err_d']:.3f})" + ) if debug_plot: import matplotlib.pyplot as plt @@ -639,19 +694,19 @@ def _check_intensity_profile_correlation( ax.axhline(y=0, color="gray", linestyle="--", alpha=0.3) ax.set_xlabel("Position (pixels from center)") ax.set_ylabel("Normalized Intensity") - ax.set_title(f"Intensity Profile Comparison (correlation: {correlation:.3f})") + ax.set_title(f"Intensity Profile Comparison (RMSE score: {score:.3f})") ax.legend() plt.tight_layout() plt.show() - passed = correlation >= control._def.INTENSITY_PROFILE_CORRELATION_THRESHOLD + passed = score <= control._def.INTENSITY_PROFILE_RMSE_THRESHOLD if not passed: self._log.warning( - f"Intensity profile correlation check failed: {correlation:.3f} < " - f"{control._def.INTENSITY_PROFILE_CORRELATION_THRESHOLD}" + f"Intensity profile RMSE check failed: {score:.3f} > " + f"{control._def.INTENSITY_PROFILE_RMSE_THRESHOLD}" ) - return passed, correlation + return passed, score def get_new_frame(self): # IMPORTANT: This assumes that the autofocus laser is already on! @@ -765,10 +820,10 @@ def _get_laser_spot_centroid( self._log.error(f"No successful detections") return None - # Perform intensity profile cross-correlation check if requested + # Perform intensity profile match check if requested if check_intensity_correlation: if intensity_profile is not None: - passed, correlation = self._check_intensity_profile_correlation(intensity_profile) + passed, score = self._check_intensity_profile_match(intensity_profile) if not passed: return None else: From a1adf46dc34aae1ea92523592a99d63cb4be6009 Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 8 Jan 2026 19:52:01 -0800 Subject: [PATCH 06/26] debug --- .../core/laser_auto_focus_controller.py | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 9efaebb63..cbddcbfea 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -414,11 +414,12 @@ def finish_with(um: float) -> float: self._log.exception("Turning off AF laser timed out! Laser may still be on.") return finish_with(float("nan")) - def move_to_target(self, target_um: float) -> bool: + def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: """Move the stage to reach a target displacement from reference position. Args: target_um: Target displacement in micrometers + debug_plot: If True, show debug plots for cross-correlation check Returns: bool: True if move was successful, False if measurement failed or displacement was out of range @@ -444,7 +445,7 @@ def move_to_target(self, target_um: float) -> bool: self._move_z(um_to_move) # Verify using cross-correlation that spot is in same location as reference - cc_result, correlation = self._verify_spot_alignment() + cc_result, correlation = self._verify_spot_alignment(debug_plot=debug_plot) self.signal_cross_correlation.emit(correlation) if not cc_result: self._log.warning("Cross correlation check failed - spots not well aligned") @@ -556,13 +557,16 @@ def on_settings_changed(self) -> None: self.is_initialized = False self.load_cached_configuration() - def _verify_spot_alignment(self) -> Tuple[bool, np.array]: + def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.array]: """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. + Args: + debug_plot: If True, show matplotlib plot comparing the two image crops + Returns: bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise """ @@ -616,6 +620,39 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: self._log.info(f"Cross correlation with reference: {correlation:.3f}") + if debug_plot: + import matplotlib.pyplot as plt + + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + + # Reference crop + axes[0].imshow(self.reference_crop, cmap="gray") + axes[0].set_title("Reference Crop (normalized)") + axes[0].axis("off") + + # Current crop + axes[1].imshow(current_norm, cmap="gray") + axes[1].set_title("Current Crop (normalized)") + axes[1].axis("off") + + # Difference image + diff = current_norm - self.reference_crop + axes[2].imshow(diff, cmap="RdBu", vmin=-0.5, vmax=0.5) + axes[2].set_title("Difference (Current - Reference)") + axes[2].axis("off") + + passed = correlation >= self.laser_af_properties.correlation_threshold + status = "PASS" if passed else "FAIL" + color = "green" if passed else "red" + fig.suptitle( + f"Cross-Correlation Check: {correlation:.3f} (threshold={self.laser_af_properties.correlation_threshold}) [{status}]", + fontsize=12, + color=color, + ) + + plt.tight_layout() + plt.show() + # Check if correlation exceeds threshold if correlation < self.laser_af_properties.correlation_threshold: self._log.warning("Cross correlation check failed - spots not well aligned") From edcc32ff40456d7a73bb61af6fdd7cbf45e7fe3a Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 8 Jan 2026 20:05:44 -0800 Subject: [PATCH 07/26] bug fix: crop --- .../core/laser_auto_focus_controller.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index cbddcbfea..441124ee4 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -585,7 +585,7 @@ def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.arr self.camera.send_trigger() current_image = self.camera.read_frame() """ - self._get_laser_spot_centroid() + centroid_result = self._get_laser_spot_centroid() current_image = self.image try: @@ -603,8 +603,13 @@ def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.arr self._log.error("Failed to get images for cross-correlation check") return failure_return_value - # Crop and normalize current image - center_x = int(self.laser_af_properties.x_reference) + if centroid_result is None: + self._log.error("Failed to detect spot centroid for cross-correlation check") + return failure_return_value + + # Crop current image around the detected peak (not the reference position) + current_peak_x, current_peak_y = centroid_result + center_x = int(current_peak_x) center_y = int(current_image.shape[0] / 2) x_start = max(0, center_x - self.laser_af_properties.spot_crop_size // 2) @@ -627,26 +632,28 @@ def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.arr # Reference crop axes[0].imshow(self.reference_crop, cmap="gray") - axes[0].set_title("Reference Crop (normalized)") + axes[0].set_title(f"Reference Crop\n(x={self.laser_af_properties.x_reference:.1f})") axes[0].axis("off") # Current crop axes[1].imshow(current_norm, cmap="gray") - axes[1].set_title("Current Crop (normalized)") + axes[1].set_title(f"Current Crop\n(x={current_peak_x:.1f})") axes[1].axis("off") # Difference image diff = current_norm - self.reference_crop axes[2].imshow(diff, cmap="RdBu", vmin=-0.5, vmax=0.5) - axes[2].set_title("Difference (Current - Reference)") + axes[2].set_title("Difference\n(Current - Reference)") axes[2].axis("off") passed = correlation >= self.laser_af_properties.correlation_threshold status = "PASS" if passed else "FAIL" color = "green" if passed else "red" + peak_diff = current_peak_x - self.laser_af_properties.x_reference fig.suptitle( - f"Cross-Correlation Check: {correlation:.3f} (threshold={self.laser_af_properties.correlation_threshold}) [{status}]", - fontsize=12, + f"Cross-Correlation: {correlation:.3f} (threshold={self.laser_af_properties.correlation_threshold}) [{status}]\n" + f"Peak shift: {peak_diff:.1f} pixels", + fontsize=11, color=color, ) From b3c7f62fb63965aad7e7c002353c2137342e4457 Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 8 Jan 2026 20:51:38 -0800 Subject: [PATCH 08/26] debug plot --- .../core/laser_auto_focus_controller.py | 62 ++++++++++++++----- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 441124ee4..0b25f05ec 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -300,9 +300,13 @@ def _get_displacement_from_centroid(self, centroid: tuple) -> float: x, y = centroid return (x - self.laser_af_properties.x_reference) * self.laser_af_properties.pixel_to_um - def measure_displacement(self, search_for_spot: bool = True) -> float: + def measure_displacement(self, search_for_spot: bool = True, debug_plot: bool = False) -> float: """Measure the displacement of the laser spot from the reference position. + Args: + search_for_spot: If True, search for spot if not found at current position + debug_plot: If True, show debug plots for intensity profile RMSE check + Returns: float: Displacement in micrometers, or float('nan') if measurement fails """ @@ -318,7 +322,7 @@ def finish_with(um: float) -> float: return finish_with(float("nan")) # get laser spot location - result = self._get_laser_spot_centroid(check_intensity_correlation=True) + result = self._get_laser_spot_centroid(check_intensity_correlation=True, debug_plot=debug_plot) if result is not None: # Spot found on first try @@ -388,7 +392,7 @@ def finish_with(um: float) -> float: current_pos_um = target_pos_um # Attempt spot detection - result = self._get_laser_spot_centroid(check_intensity_correlation=True) + result = self._get_laser_spot_centroid(check_intensity_correlation=True, debug_plot=debug_plot) if result is not None: displacement_um = self._get_displacement_from_centroid(result) @@ -403,9 +407,7 @@ def finish_with(um: float) -> float: return finish_with(displacement_um) # Spot not found - move back to original position - move_back_um = current_z_um - current_pos_um - if move_back_um != 0: - self._move_z(move_back_um) + self._restore_to_position(current_z_um) self._log.warning("Spot not found during z search") try: @@ -428,17 +430,23 @@ def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: self._log.warning("Cannot move to target - reference not set") return False - current_displacement_um = self.measure_displacement() + # Record original z position so we can restore it on failure + if self.piezo is not None: + original_z_um = self.piezo.position + else: + original_z_um = self.stage.get_pos().z_mm * 1000 + + current_displacement_um = self.measure_displacement(debug_plot=debug_plot) self._log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") if math.isnan(current_displacement_um): self._log.error("Cannot move to target: failed to measure current displacement") + # measure_displacement already restores position on search failure return False if abs(current_displacement_um) > self.laser_af_properties.laser_af_range: - self._log.warning( - f"Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" - ) + self._log.warning(f"Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large") + self._restore_to_position(original_z_um) return False um_to_move = target_um - current_displacement_um @@ -449,13 +457,25 @@ def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: self.signal_cross_correlation.emit(correlation) if not cc_result: self._log.warning("Cross correlation check failed - spots not well aligned") - # move back to the current position - self._move_z(-um_to_move) + # Restore to original position (not just undo last move) + self._restore_to_position(original_z_um) return False else: self._log.info("Cross correlation check passed - spots are well aligned") return True + def _restore_to_position(self, target_z_um: float) -> None: + """Restore z position to a specific absolute position.""" + if self.piezo is not None: + current_z_um = self.piezo.position + else: + current_z_um = self.stage.get_pos().z_mm * 1000 + + move_um = target_z_um - current_z_um + if abs(move_um) > 0.01: # Only move if difference is significant + self._log.info(f"Restoring z position: moving {move_um:.1f} μm") + self._move_z(move_um) + def _move_z(self, um_to_move: float) -> None: if self.piezo is not None: # TODO: check if um_to_move is in the range of the piezo @@ -525,12 +545,20 @@ def set_reference(self) -> bool: self.reference_crop = (reference_crop - np.mean(reference_crop)) / np.max(reference_crop) self.reference_intensity_profile = reference_intensity_profile + self._log.info( + f"Reference crop updated: shape={self.reference_crop.shape}, " + f"crop region=[{x_start}:{x_end}, {y_start}:{y_end}]" + ) + self.signal_displacement_um.emit(0) self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") - self.laser_af_properties = self.laser_af_properties.model_copy( - update={"x_reference": x, "has_reference": True} - ) # We don't keep reference_crop here to avoid serializing it + self.laser_af_properties = self.laser_af_properties.model_copy(update={"x_reference": x, "has_reference": True}) + # Also update the reference image and intensity profile in laser_af_properties + # so that self.laser_af_properties.reference_image_cropped stays in sync with self.reference_crop + self.laser_af_properties.set_reference_image(self.reference_crop) + if reference_intensity_profile is not None: + self.laser_af_properties.set_reference_intensity_profile(reference_intensity_profile) # Update cached file. reference_crop and intensity_profile need to be saved. self.laserAFSettingManager.update_laser_af_settings( @@ -762,6 +790,7 @@ def _get_laser_spot_centroid( remove_background: bool = False, use_center_crop: Optional[Tuple[int, int]] = None, check_intensity_correlation: bool = False, + debug_plot: bool = False, ) -> Optional[Tuple[float, float]]: """Get the centroid location of the laser spot. @@ -772,6 +801,7 @@ def _get_laser_spot_centroid( remove_background: Apply background removal using top-hat filter use_center_crop: (width, height) to crop around center before detection check_intensity_correlation: If True, verify intensity profile correlation with reference + debug_plot: If True, show debug plot for intensity profile RMSE check Returns: Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails @@ -867,7 +897,7 @@ def _get_laser_spot_centroid( # Perform intensity profile match check if requested if check_intensity_correlation: if intensity_profile is not None: - passed, score = self._check_intensity_profile_match(intensity_profile) + passed, score = self._check_intensity_profile_match(intensity_profile, debug_plot=debug_plot) if not passed: return None else: From b253d8d31fe27d5904c91cead35b23d2d506cb0c Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 8 Jan 2026 22:23:29 -0800 Subject: [PATCH 09/26] test: new normalization method --- .../control/core/laser_af_settings_manager.py | 6 +- .../core/laser_auto_focus_controller.py | 30 ++++++++-- software/control/utils.py | 58 ++++++++++++++----- software/control/utils_config.py | 21 ++++++- software/control/widgets.py | 2 +- .../control/test_spot_detection_manual.py | 4 +- software/tests/control/test_utils.py | 11 ++-- 7 files changed, 102 insertions(+), 30 deletions(-) diff --git a/software/control/core/laser_af_settings_manager.py b/software/control/core/laser_af_settings_manager.py index 311eb15b3..068e647ab 100644 --- a/software/control/core/laser_af_settings_manager.py +++ b/software/control/core/laser_af_settings_manager.py @@ -53,6 +53,8 @@ def update_laser_af_settings( updates: Dict[str, Any], crop_image: Optional[np.ndarray] = None, intensity_profile: Optional[np.ndarray] = None, + intensity_min: Optional[float] = None, + intensity_max: Optional[float] = None, ) -> None: if objective not in self.autofocus_configurations: self.autofocus_configurations[objective] = LaserAFConfig(**updates) @@ -62,4 +64,6 @@ def update_laser_af_settings( if crop_image is not None: self.autofocus_configurations[objective].set_reference_image(crop_image) if intensity_profile is not None: - self.autofocus_configurations[objective].set_reference_intensity_profile(intensity_profile) + self.autofocus_configurations[objective].set_reference_intensity_profile( + intensity_profile, intensity_min=intensity_min, intensity_max=intensity_max + ) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 0b25f05ec..2b003562f 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -57,6 +57,8 @@ def __init__( self.image = None # for saving the focus camera image for debugging when centroid cannot be found self.intensity_profile = None # temporary storage for intensity profile during set_reference + self.intensity_min = None # temporary storage for intensity min during set_reference + self.intensity_max = None # temporary storage for intensity max during set_reference # Load configurations if provided if self.laserAFSettingManager: @@ -509,6 +511,8 @@ def set_reference(self) -> bool: result = self._get_laser_spot_centroid() reference_image = self.image reference_intensity_profile = self.intensity_profile + reference_intensity_min = self.intensity_min + reference_intensity_max = self.intensity_max # turn off the laser try: @@ -558,7 +562,11 @@ def set_reference(self) -> bool: # so that self.laser_af_properties.reference_image_cropped stays in sync with self.reference_crop self.laser_af_properties.set_reference_image(self.reference_crop) if reference_intensity_profile is not None: - self.laser_af_properties.set_reference_intensity_profile(reference_intensity_profile) + self.laser_af_properties.set_reference_intensity_profile( + reference_intensity_profile, + intensity_min=reference_intensity_min, + intensity_max=reference_intensity_max, + ) # Update cached file. reference_crop and intensity_profile need to be saved. self.laserAFSettingManager.update_laser_af_settings( @@ -569,6 +577,8 @@ def set_reference(self) -> bool: }, crop_image=self.reference_crop, intensity_profile=reference_intensity_profile, + intensity_min=reference_intensity_min, + intensity_max=reference_intensity_max, ) self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) @@ -842,11 +852,21 @@ def _get_laser_spot_centroid( "peak_prominence": self.laser_af_properties.min_peak_prominence, "spot_spacing": self.laser_af_properties.spot_spacing, } + + # Pass reference intensity min/max for normalization during measurement + ref_min = None + ref_max = None + if check_intensity_correlation: + ref_min = self.laser_af_properties.reference_intensity_min + ref_max = self.laser_af_properties.reference_intensity_max + result = utils.find_spot_location( image, mode=self.laser_af_properties.spot_detection_mode, params=spot_detection_params, filter_sigma=self.laser_af_properties.filter_sigma, + reference_intensity_min=ref_min, + reference_intensity_max=ref_max, ) if result is None: self._log.warning( @@ -854,8 +874,8 @@ def _get_laser_spot_centroid( ) continue - # Unpack result: (centroid_x, centroid_y, intensity_profile) - spot_x, spot_y, intensity_profile = result + # Unpack result: (centroid_x, centroid_y, intensity_profile, intensity_min, intensity_max) + spot_x, spot_y, intensity_profile, intensity_min, intensity_max = result if use_center_crop is not None: x, y = ( @@ -901,8 +921,10 @@ def _get_laser_spot_centroid( if not passed: return None else: - # Store the intensity profile for later use (e.g., when setting reference) + # Store the intensity profile and min/max for later use (e.g., when setting reference) self.intensity_profile = intensity_profile + self.intensity_min = intensity_min + self.intensity_max = intensity_max # Calculate average position from successful detections x = tmp_x / successful_detections diff --git a/software/control/utils.py b/software/control/utils.py index 97267417b..c47101d54 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -208,7 +208,9 @@ def find_spot_location( params: Optional[dict] = None, filter_sigma: Optional[int] = None, debug_plot: bool = False, -) -> Optional[Tuple[float, float, np.ndarray]]: + reference_intensity_min: Optional[float] = None, + reference_intensity_max: Optional[float] = None, +) -> Optional[Tuple[float, float, np.ndarray, float, float]]: """Find the location of a spot in an image. Args: @@ -223,11 +225,16 @@ def find_spot_location( - 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) + filter_sigma: Sigma for Gaussian filter, or None to skip filtering + debug_plot: If True, show debug plots + reference_intensity_min: Min intensity for normalization. If None, use current image's min. + reference_intensity_max: Max intensity for normalization. If None, use current image's max. Returns: - Optional[Tuple[float, float, np.ndarray]]: (x, y, intensity_profile) where intensity_profile is a - 1D array of length 400 (INTENSITY_PROFILE_HALF_WIDTH * 2) centered at the detected peak, - or None if detection fails + Optional[Tuple[float, float, np.ndarray, float, float]]: (x, y, intensity_profile, intensity_min, intensity_max) + where intensity_profile is a 1D array of length 400 (INTENSITY_PROFILE_HALF_WIDTH * 2) centered + at the detected peak, and intensity_min/max are the values used for normalization. + Returns None if detection fails. Raises: ValueError: If image is invalid or mode is incompatible with detected spots @@ -271,12 +278,15 @@ def find_spot_location( # Crop along the y axis cropped_image = image[peak_y - p["y_window"] : peak_y + p["y_window"], :] - # Get signal along x - x_intensity_profile = np.sum(cropped_image, axis=0) + # Get signal along x (raw intensities) + x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) - # 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) + # Compute local min/max for normalization + local_min = np.min(x_intensity_profile_raw) + local_max = np.max(x_intensity_profile_raw) + + # Normalize intensity profile for peak detection (always use local normalization) + x_intensity_profile = (x_intensity_profile_raw - local_min) / (local_max - local_min) # Find all peaks peaks = signal.find_peaks( @@ -312,21 +322,36 @@ def find_spot_location( else: raise ValueError(f"Unknown spot detection mode: {mode}") - # Extract centered intensity profile (200 pixels on each side of peak) + # Extract centered intensity profile from raw values (200 pixels on each side of peak) profile_start = peak_x - INTENSITY_PROFILE_HALF_WIDTH profile_end = peak_x + INTENSITY_PROFILE_HALF_WIDTH profile_length = INTENSITY_PROFILE_HALF_WIDTH * 2 - if profile_start < 0 or profile_end > len(x_intensity_profile): + if profile_start < 0 or profile_end > len(x_intensity_profile_raw): # Handle edge cases with padding - centered_profile = np.zeros(profile_length, dtype=x_intensity_profile.dtype) + centered_profile_raw = np.zeros(profile_length, dtype=x_intensity_profile_raw.dtype) src_start = max(0, profile_start) - src_end = min(len(x_intensity_profile), profile_end) + src_end = min(len(x_intensity_profile_raw), profile_end) dst_start = max(0, -profile_start) dst_end = dst_start + (src_end - src_start) - centered_profile[dst_start:dst_end] = x_intensity_profile[src_start:src_end] + centered_profile_raw[dst_start:dst_end] = x_intensity_profile_raw[src_start:src_end] + else: + centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() + + # Normalize the centered profile + # Use reference min/max if provided (for measurement), otherwise use local (for initialization) + if reference_intensity_min is not None and reference_intensity_max is not None: + norm_min = reference_intensity_min + norm_max = reference_intensity_max + else: + norm_min = local_min + norm_max = local_max + + # Avoid division by zero + if norm_max - norm_min > 0: + centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) else: - centered_profile = x_intensity_profile[profile_start:profile_end].copy() + centered_profile = np.zeros_like(centered_profile_raw) if debug_plot: import matplotlib.pyplot as plt @@ -374,7 +399,8 @@ def find_spot_location( # Calculate centroid in window around selected peak centroid_x, centroid_y = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) - return (centroid_x, centroid_y, centered_profile) + # Return local min/max (the raw intensity values from this image) + return (centroid_x, centroid_y, centered_profile, local_min, local_max) except (ValueError, NotImplementedError) as e: raise e diff --git a/software/control/utils_config.py b/software/control/utils_config.py index bef5cd69d..390defaba 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -65,6 +65,8 @@ class LaserAFConfig(BaseModel): reference_image_dtype: Optional[str] = None reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check reference_intensity_profile_dtype: Optional[str] = None + reference_intensity_min: Optional[float] = None # Min intensity value used for reference normalization + reference_intensity_max: Optional[float] = None # Max intensity value used for reference normalization initialize_crop_width: int = 1200 # Width of the center crop used for initialization initialize_crop_height: int = 800 # Height of the center crop used for initialization @@ -105,14 +107,29 @@ def set_reference_image(self, image: Optional[np.ndarray]) -> None: self.reference_image_dtype = str(image.dtype) self.has_reference = True - def set_reference_intensity_profile(self, profile: Optional[np.ndarray]) -> None: - """Convert numpy array to base64 encoded string or clear if None""" + def set_reference_intensity_profile( + self, + profile: Optional[np.ndarray], + intensity_min: Optional[float] = None, + intensity_max: Optional[float] = None, + ) -> None: + """Convert numpy array to base64 encoded string or clear if None. + + Args: + profile: The intensity profile array, or None to clear + intensity_min: Min intensity value used for normalization + intensity_max: Max intensity value used for normalization + """ if profile is None: self.reference_intensity_profile = None self.reference_intensity_profile_dtype = None + self.reference_intensity_min = None + self.reference_intensity_max = None return self.reference_intensity_profile = base64.b64encode(profile.tobytes()).decode("utf-8") self.reference_intensity_profile_dtype = str(profile.dtype) + self.reference_intensity_min = intensity_min + self.reference_intensity_max = intensity_max def model_dump(self, serialize=False, **kwargs): """Ensure proper serialization of enums to strings""" diff --git a/software/control/widgets.py b/software/control/widgets.py index 91644955c..7b79f0f0f 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -766,7 +766,7 @@ def run_spot_detection(self): try: result = utils.find_spot_location(frame, mode=mode, params=params, filter_sigma=sigma, debug_plot=True) if result is not None: - x, y, _ = result # Unpack centroid (x, y) and ignore intensity_profile + x, y, _, _, _ = result # Unpack centroid (x, y) and ignore intensity_profile and min/max self.signal_laser_spot_location.emit(frame, x, y) else: raise Exception("No spot detection result returned") diff --git a/software/tests/control/test_spot_detection_manual.py b/software/tests/control/test_spot_detection_manual.py index cdec7ca27..d60e44f64 100644 --- a/software/tests/control/test_spot_detection_manual.py +++ b/software/tests/control/test_spot_detection_manual.py @@ -56,8 +56,8 @@ def check_image_from_disk(image_path: str): ) if result is not None: - x, y, _ = result - print(f"Found spot at: ({x:.1f}, {y:.1f})") + x, y, _, intensity_min, intensity_max = result + print(f"Found spot at: ({x:.1f}, {y:.1f}), intensity range: [{intensity_min:.1f}, {intensity_max:.1f}]") else: print("No spot detected") diff --git a/software/tests/control/test_utils.py b/software/tests/control/test_utils.py index 8300d34ce..4d6d78604 100644 --- a/software/tests/control/test_utils.py +++ b/software/tests/control/test_utils.py @@ -45,11 +45,14 @@ def test_single_spot_detection(): result = find_spot_location(image, mode=SpotDetectionMode.SINGLE) assert result is not None - detected_x, detected_y, intensity_profile = result + detected_x, detected_y, intensity_profile, intensity_min, intensity_max = result assert abs(detected_x - spot_x) < 5 assert abs(detected_y - spot_y) < 5 assert intensity_profile is not None assert len(intensity_profile) == 400 # INTENSITY_PROFILE_HALF_WIDTH * 2 + assert intensity_min is not None + assert intensity_max is not None + assert intensity_max > intensity_min def test_dual_spot_detection(): @@ -60,13 +63,13 @@ def test_dual_spot_detection(): # Test right spot detection result = find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) assert result is not None - detected_x, detected_y, _ = result + 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 + detected_x, detected_y, _, _, _ = result assert abs(detected_x - spots[0][0]) < 5 @@ -78,7 +81,7 @@ def test_multi_spot_detection(): # Test rightmost spot detection result = find_spot_location(image, mode=SpotDetectionMode.MULTI_RIGHT) assert result - detected_x, detected_y, _ = result + detected_x, detected_y, _, _, _ = result assert abs(detected_x - spots[2][0]) < 5 # Test second from right spot detection From 425db31beaf91ac756c9f9ba84e4c787491d861b Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 9 Jan 2026 15:02:19 -0800 Subject: [PATCH 10/26] use simple rmse --- software/control/_def.py | 4 ++- .../core/laser_auto_focus_controller.py | 34 ++++--------------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 81a2e90dc..7a8ee8358 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -602,7 +602,9 @@ class SOFTWARE_POS_LIMIT: DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 SPOT_CROP_SIZE = 100 CORRELATION_THRESHOLD = 0.7 -INTENSITY_PROFILE_RMSE_THRESHOLD = 1.0 # Maximum RMSE score for intensity profile match (lower is better) +INTENSITY_PROFILE_RMSE_THRESHOLD = ( + 0.2 # Maximum RMSE for intensity profile match (lower is better, profiles normalized 0-1) +) INTENSITY_PROFILE_HALF_WIDTH = 200 # Half-width of centered intensity profile (total length = 400) PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 LASER_AF_Y_WINDOW = 96 diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 2b003562f..a6aa1bba7 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -705,42 +705,22 @@ def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.arr return True, correlation - def _compute_intensity_profile_rmse( - self, y: np.ndarray, r: np.ndarray, edge_frac: float = 0.1, k: float = 3.0, lam: float = 0.5 - ) -> Tuple[float, dict]: - """Compute RMSE-based match score between two intensity profiles. - - Uses log-transformed RMSE with automatic delta estimation from edge noise, - plus optional derivative term for shape matching. + def _compute_intensity_profile_rmse(self, y: np.ndarray, r: np.ndarray) -> Tuple[float, dict]: + """Compute RMSE between two intensity profiles. Args: y: Current intensity profile r: Reference intensity profile - edge_frac: Fraction of edges to use for noise estimation - k: Multiplier for noise-based delta (typically 2-5) - lam: Weight for derivative term Returns: - Tuple[float, dict]: (score, details) where score is combined RMSE (lower is better) + Tuple[float, dict]: (rmse, details) where rmse is root mean square error (lower is better) """ y = np.asarray(y, float) r = np.asarray(r, float) - # Estimate delta from measured profile edges - n = y.size - m = max(1, int(edge_frac * n)) - edges = np.r_[y[:m], y[-m:]] - sigma = float(np.std(edges)) - delta = max(1e-4, k * sigma) - - ly = np.log(y + delta) - lr = np.log(r + delta) + rmse = float(np.sqrt(np.mean((y - r) ** 2))) - err = float(np.sqrt(np.mean((ly - lr) ** 2))) - err_d = float(np.sqrt(np.mean((np.diff(ly) - np.diff(lr)) ** 2))) - score = err + lam * err_d - - return score, {"err": err, "err_d": err_d, "score": score, "delta": delta, "sigma_edge": sigma, "lam": lam} + return rmse, {"rmse": rmse} def _check_intensity_profile_match( self, intensity_profile: np.ndarray, debug_plot: bool = False @@ -762,9 +742,7 @@ def _check_intensity_profile_match( score, details = self._compute_intensity_profile_rmse( intensity_profile.ravel(), self.reference_intensity_profile.ravel() ) - self._log.debug( - f"Intensity profile RMSE score: {score:.3f} (err={details['err']:.3f}, err_d={details['err_d']:.3f})" - ) + self._log.debug(f"Intensity profile RMSE: {score:.3f}") if debug_plot: import matplotlib.pyplot as plt From 7afb4e4da6c2545ec3f66ce0bc032b9793971aad Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 9 Jan 2026 15:48:28 -0800 Subject: [PATCH 11/26] debug plot --- .../core/laser_auto_focus_controller.py | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index a6aa1bba7..e2ca94f4c 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -302,12 +302,11 @@ def _get_displacement_from_centroid(self, centroid: tuple) -> float: x, y = centroid return (x - self.laser_af_properties.x_reference) * self.laser_af_properties.pixel_to_um - def measure_displacement(self, search_for_spot: bool = True, debug_plot: bool = False) -> float: + def measure_displacement(self, search_for_spot: bool = True) -> float: """Measure the displacement of the laser spot from the reference position. Args: search_for_spot: If True, search for spot if not found at current position - debug_plot: If True, show debug plots for intensity profile RMSE check Returns: float: Displacement in micrometers, or float('nan') if measurement fails @@ -324,7 +323,7 @@ def finish_with(um: float) -> float: return finish_with(float("nan")) # get laser spot location - result = self._get_laser_spot_centroid(check_intensity_correlation=True, debug_plot=debug_plot) + result = self._get_laser_spot_centroid(check_intensity_correlation=True) if result is not None: # Spot found on first try @@ -394,7 +393,7 @@ def finish_with(um: float) -> float: current_pos_um = target_pos_um # Attempt spot detection - result = self._get_laser_spot_centroid(check_intensity_correlation=True, debug_plot=debug_plot) + result = self._get_laser_spot_centroid(check_intensity_correlation=True) if result is not None: displacement_um = self._get_displacement_from_centroid(result) @@ -418,12 +417,11 @@ def finish_with(um: float) -> float: self._log.exception("Turning off AF laser timed out! Laser may still be on.") return finish_with(float("nan")) - def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: + 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 - debug_plot: If True, show debug plots for cross-correlation check Returns: bool: True if move was successful, False if measurement failed or displacement was out of range @@ -438,7 +436,7 @@ def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: else: original_z_um = self.stage.get_pos().z_mm * 1000 - current_displacement_um = self.measure_displacement(debug_plot=debug_plot) + current_displacement_um = self.measure_displacement() self._log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") if math.isnan(current_displacement_um): @@ -455,7 +453,7 @@ def move_to_target(self, target_um: float, debug_plot: bool = False) -> bool: self._move_z(um_to_move) # Verify using cross-correlation that spot is in same location as reference - cc_result, correlation = self._verify_spot_alignment(debug_plot=debug_plot) + cc_result, correlation = self._verify_spot_alignment() self.signal_cross_correlation.emit(correlation) if not cc_result: self._log.warning("Cross correlation check failed - spots not well aligned") @@ -595,16 +593,13 @@ def on_settings_changed(self) -> None: self.is_initialized = False self.load_cached_configuration() - def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.array]: + def _verify_spot_alignment(self) -> Tuple[bool, np.array]: """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. - Args: - debug_plot: If True, show matplotlib plot comparing the two image crops - Returns: bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise """ @@ -663,7 +658,7 @@ def _verify_spot_alignment(self, debug_plot: bool = False) -> Tuple[bool, np.arr self._log.info(f"Cross correlation with reference: {correlation:.3f}") - if debug_plot: + if False: # Set to True to enable debug plot import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 3, figsize=(12, 4)) @@ -722,14 +717,11 @@ def _compute_intensity_profile_rmse(self, y: np.ndarray, r: np.ndarray) -> Tuple return rmse, {"rmse": rmse} - def _check_intensity_profile_match( - self, intensity_profile: np.ndarray, debug_plot: bool = False - ) -> Tuple[bool, float]: + def _check_intensity_profile_match(self, intensity_profile: np.ndarray) -> Tuple[bool, float]: """Check if current intensity profile matches reference using RMSE score. Args: intensity_profile: Current intensity profile to compare - debug_plot: If True, show a matplotlib plot comparing the profiles Returns: Tuple[bool, float]: (passed, score) where passed is True if score <= threshold @@ -744,7 +736,7 @@ def _check_intensity_profile_match( ) self._log.debug(f"Intensity profile RMSE: {score:.3f}") - if debug_plot: + if False: # Set to True to enable debug plot import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(10, 4)) @@ -778,7 +770,6 @@ def _get_laser_spot_centroid( remove_background: bool = False, use_center_crop: Optional[Tuple[int, int]] = None, check_intensity_correlation: bool = False, - debug_plot: bool = False, ) -> Optional[Tuple[float, float]]: """Get the centroid location of the laser spot. @@ -789,7 +780,6 @@ def _get_laser_spot_centroid( remove_background: Apply background removal using top-hat filter use_center_crop: (width, height) to crop around center before detection check_intensity_correlation: If True, verify intensity profile correlation with reference - debug_plot: If True, show debug plot for intensity profile RMSE check Returns: Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails @@ -895,7 +885,7 @@ def _get_laser_spot_centroid( # Perform intensity profile match check if requested if check_intensity_correlation: if intensity_profile is not None: - passed, score = self._check_intensity_profile_match(intensity_profile, debug_plot=debug_plot) + passed, score = self._check_intensity_profile_match(intensity_profile) if not passed: return None else: From 39b663b007965376b24483b18308b5664f8a3add Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 18:41:49 -0800 Subject: [PATCH 12/26] normalization fix --- software/control/utils.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/software/control/utils.py b/software/control/utils.py index c47101d54..b13ad481f 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -285,8 +285,19 @@ def find_spot_location( local_min = np.min(x_intensity_profile_raw) local_max = np.max(x_intensity_profile_raw) - # Normalize intensity profile for peak detection (always use local normalization) - x_intensity_profile = (x_intensity_profile_raw - local_min) / (local_max - local_min) + # Determine normalization values: use reference if provided, otherwise local + if reference_intensity_min is not None and reference_intensity_max is not None: + norm_min = reference_intensity_min + norm_max = reference_intensity_max + else: + norm_min = local_min + norm_max = local_max + + # Normalize intensity profile for peak detection + if norm_max - norm_min > 0: + x_intensity_profile = (x_intensity_profile_raw - norm_min) / (norm_max - norm_min) + else: + raise ValueError("Cannot normalize: norm_max equals norm_min") # Find all peaks peaks = signal.find_peaks( @@ -338,15 +349,7 @@ def find_spot_location( else: centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() - # Normalize the centered profile - # Use reference min/max if provided (for measurement), otherwise use local (for initialization) - if reference_intensity_min is not None and reference_intensity_max is not None: - norm_min = reference_intensity_min - norm_max = reference_intensity_max - else: - norm_min = local_min - norm_max = local_max - + # Normalize the centered profile (using same norm_min/norm_max as peak detection) # Avoid division by zero if norm_max - norm_min > 0: centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) From 18b8f051ab7994df0866f35189d20badb921c4d8 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 19:20:58 -0800 Subject: [PATCH 13/26] logging --- .../core/laser_auto_focus_controller.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index e2ca94f4c..ba7e44303 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -389,23 +389,32 @@ def finish_with(um: float) -> float: # Move to target position move_um = target_pos_um - current_pos_um if move_um != 0: + self._log.info(f"Z search: moving to {target_pos_um:.1f} um (delta: {move_um:+.1f} um)") self._move_z(move_um) current_pos_um = target_pos_um + else: + self._log.info(f"Z search: checking current position {target_pos_um:.1f} um") # Attempt spot detection result = self._get_laser_spot_centroid(check_intensity_correlation=True) - if result is not None: - displacement_um = self._get_displacement_from_centroid(result) - if abs(displacement_um) <= search_step_um + 4: - self._log.info( - f"Spot found at z position {target_pos_um} um, displacement {displacement_um:.1f} um" - ) - try: - self._turn_off_laser() - except TimeoutError: - self._log.exception("Turning off AF laser timed out! Laser may still be on.") - return finish_with(displacement_um) + if result is None: + self._log.info(f"Z search: no valid spot at {target_pos_um:.1f} um") + continue + + displacement_um = self._get_displacement_from_centroid(result) + if abs(displacement_um) > search_step_um + 4: + self._log.info( + f"Z search: spot at {target_pos_um:.1f} um has displacement {displacement_um:.1f} um (out of range)" + ) + continue + + self._log.info(f"Z search: spot found at {target_pos_um:.1f} um, displacement {displacement_um:.1f} um") + try: + self._turn_off_laser() + except TimeoutError: + self._log.exception("Turning off AF laser timed out! Laser may still be on.") + return finish_with(displacement_um) # Spot not found - move back to original position self._restore_to_position(current_z_um) From 2a49f6020859b6530430aea59d8960d0b617de20 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 20:12:51 -0800 Subject: [PATCH 14/26] normalization fix --- .../core/laser_auto_focus_controller.py | 8 ++++++-- software/control/utils.py | 20 +++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index ba7e44303..253f64da8 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -553,7 +553,9 @@ def set_reference(self) -> bool: ) 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.reference_crop = (reference_crop - reference_intensity_min) / ( + reference_intensity_max - reference_intensity_min + ) self.reference_intensity_profile = reference_intensity_profile self._log.info( @@ -660,7 +662,9 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: y_end = min(current_image.shape[0], center_y + self.laser_af_properties.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) + ref_min = self.laser_af_properties.reference_intensity_min + ref_max = self.laser_af_properties.reference_intensity_max + current_norm = (current_crop - ref_min) / (ref_max - ref_min) # Calculate normalized cross correlation correlation = np.corrcoef(current_norm.ravel(), self.reference_crop.ravel())[0, 1] diff --git a/software/control/utils.py b/software/control/utils.py index b13ad481f..c88ea4720 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -281,9 +281,9 @@ def find_spot_location( # Get signal along x (raw intensities) x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) - # Compute local min/max for normalization - local_min = np.min(x_intensity_profile_raw) - local_max = np.max(x_intensity_profile_raw) + # Compute local min/max for normalization (pixel values, not sums) + local_min = float(np.min(cropped_image)) + local_max = float(np.max(cropped_image)) # Determine normalization values: use reference if provided, otherwise local if reference_intensity_min is not None and reference_intensity_max is not None: @@ -294,8 +294,12 @@ def find_spot_location( norm_max = local_max # Normalize intensity profile for peak detection - if norm_max - norm_min > 0: - x_intensity_profile = (x_intensity_profile_raw - norm_min) / (norm_max - norm_min) + # Scale pixel min/max by y_window size since profile values are sums over 2*y_window pixels + y_window_size = 2 * p["y_window"] + profile_norm_min = norm_min * y_window_size + profile_norm_max = norm_max * y_window_size + if profile_norm_max - profile_norm_min > 0: + x_intensity_profile = (x_intensity_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) else: raise ValueError("Cannot normalize: norm_max equals norm_min") @@ -349,10 +353,10 @@ def find_spot_location( else: centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() - # Normalize the centered profile (using same norm_min/norm_max as peak detection) + # Normalize the centered profile (using same scaled values as peak detection) # Avoid division by zero - if norm_max - norm_min > 0: - centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) + if profile_norm_max - profile_norm_min > 0: + centered_profile = (centered_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) else: centered_profile = np.zeros_like(centered_profile_raw) From 9a7bff53e033eacdde347c8563cce4e1d1461ce6 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 20:44:18 -0800 Subject: [PATCH 15/26] normalization fi --- .../core/laser_auto_focus_controller.py | 21 +++++++++++++------ software/control/utils.py | 20 +++++++----------- software/control/utils_config.py | 2 ++ 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 253f64da8..0d5538e3e 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -553,9 +553,9 @@ def set_reference(self) -> bool: ) reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) - self.reference_crop = (reference_crop - reference_intensity_min) / ( - reference_intensity_max - reference_intensity_min - ) + reference_crop_min = float(np.min(reference_crop)) + reference_crop_max = float(np.max(reference_crop)) + self.reference_crop = (reference_crop - reference_crop_min) / (reference_crop_max - reference_crop_min) self.reference_intensity_profile = reference_intensity_profile self._log.info( @@ -566,7 +566,14 @@ def set_reference(self) -> bool: self.signal_displacement_um.emit(0) self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") - self.laser_af_properties = self.laser_af_properties.model_copy(update={"x_reference": x, "has_reference": True}) + self.laser_af_properties = self.laser_af_properties.model_copy( + update={ + "x_reference": x, + "has_reference": True, + "reference_crop_min": reference_crop_min, + "reference_crop_max": reference_crop_max, + } + ) # Also update the reference image and intensity profile in laser_af_properties # so that self.laser_af_properties.reference_image_cropped stays in sync with self.reference_crop self.laser_af_properties.set_reference_image(self.reference_crop) @@ -583,6 +590,8 @@ def set_reference(self) -> bool: { "x_reference": x + self.laser_af_properties.x_offset, "has_reference": True, + "reference_crop_min": reference_crop_min, + "reference_crop_max": reference_crop_max, }, crop_image=self.reference_crop, intensity_profile=reference_intensity_profile, @@ -662,8 +671,8 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: y_end = min(current_image.shape[0], center_y + self.laser_af_properties.spot_crop_size // 2) current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) - ref_min = self.laser_af_properties.reference_intensity_min - ref_max = self.laser_af_properties.reference_intensity_max + ref_min = self.laser_af_properties.reference_crop_min + ref_max = self.laser_af_properties.reference_crop_max current_norm = (current_crop - ref_min) / (ref_max - ref_min) # Calculate normalized cross correlation diff --git a/software/control/utils.py b/software/control/utils.py index c88ea4720..eabf95698 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -281,9 +281,9 @@ def find_spot_location( # Get signal along x (raw intensities) x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) - # Compute local min/max for normalization (pixel values, not sums) - local_min = float(np.min(cropped_image)) - local_max = float(np.max(cropped_image)) + # Compute local min/max for normalization (from sums) + local_min = np.min(x_intensity_profile_raw) + local_max = np.max(x_intensity_profile_raw) # Determine normalization values: use reference if provided, otherwise local if reference_intensity_min is not None and reference_intensity_max is not None: @@ -294,12 +294,8 @@ def find_spot_location( norm_max = local_max # Normalize intensity profile for peak detection - # Scale pixel min/max by y_window size since profile values are sums over 2*y_window pixels - y_window_size = 2 * p["y_window"] - profile_norm_min = norm_min * y_window_size - profile_norm_max = norm_max * y_window_size - if profile_norm_max - profile_norm_min > 0: - x_intensity_profile = (x_intensity_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) + if norm_max - norm_min > 0: + x_intensity_profile = (x_intensity_profile_raw - norm_min) / (norm_max - norm_min) else: raise ValueError("Cannot normalize: norm_max equals norm_min") @@ -353,10 +349,10 @@ def find_spot_location( else: centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() - # Normalize the centered profile (using same scaled values as peak detection) + # Normalize the centered profile (using same norm_min/norm_max as peak detection) # Avoid division by zero - if profile_norm_max - profile_norm_min > 0: - centered_profile = (centered_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) + if norm_max - norm_min > 0: + centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) else: centered_profile = np.zeros_like(centered_profile_raw) diff --git a/software/control/utils_config.py b/software/control/utils_config.py index 390defaba..fa071a7f4 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -63,6 +63,8 @@ class LaserAFConfig(BaseModel): reference_image: Optional[str] = None # Stores base64 encoded reference image for cross-correlation check reference_image_shape: Optional[tuple] = None reference_image_dtype: Optional[str] = None + reference_crop_min: Optional[float] = None # Min pixel value used for crop normalization + reference_crop_max: Optional[float] = None # Max pixel value used for crop normalization reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check reference_intensity_profile_dtype: Optional[str] = None reference_intensity_min: Optional[float] = None # Min intensity value used for reference normalization From b3a15a9948afe345664289ffeb50bbca056b1851 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 20:54:56 -0800 Subject: [PATCH 16/26] normalization fix --- .../core/laser_auto_focus_controller.py | 21 ++++++------------- software/control/utils.py | 20 +++++++++++------- software/control/utils_config.py | 6 ++---- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 0d5538e3e..253f64da8 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -553,9 +553,9 @@ def set_reference(self) -> bool: ) reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) - reference_crop_min = float(np.min(reference_crop)) - reference_crop_max = float(np.max(reference_crop)) - self.reference_crop = (reference_crop - reference_crop_min) / (reference_crop_max - reference_crop_min) + self.reference_crop = (reference_crop - reference_intensity_min) / ( + reference_intensity_max - reference_intensity_min + ) self.reference_intensity_profile = reference_intensity_profile self._log.info( @@ -566,14 +566,7 @@ def set_reference(self) -> bool: self.signal_displacement_um.emit(0) self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") - self.laser_af_properties = self.laser_af_properties.model_copy( - update={ - "x_reference": x, - "has_reference": True, - "reference_crop_min": reference_crop_min, - "reference_crop_max": reference_crop_max, - } - ) + self.laser_af_properties = self.laser_af_properties.model_copy(update={"x_reference": x, "has_reference": True}) # Also update the reference image and intensity profile in laser_af_properties # so that self.laser_af_properties.reference_image_cropped stays in sync with self.reference_crop self.laser_af_properties.set_reference_image(self.reference_crop) @@ -590,8 +583,6 @@ def set_reference(self) -> bool: { "x_reference": x + self.laser_af_properties.x_offset, "has_reference": True, - "reference_crop_min": reference_crop_min, - "reference_crop_max": reference_crop_max, }, crop_image=self.reference_crop, intensity_profile=reference_intensity_profile, @@ -671,8 +662,8 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: y_end = min(current_image.shape[0], center_y + self.laser_af_properties.spot_crop_size // 2) current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) - ref_min = self.laser_af_properties.reference_crop_min - ref_max = self.laser_af_properties.reference_crop_max + ref_min = self.laser_af_properties.reference_intensity_min + ref_max = self.laser_af_properties.reference_intensity_max current_norm = (current_crop - ref_min) / (ref_max - ref_min) # Calculate normalized cross correlation diff --git a/software/control/utils.py b/software/control/utils.py index eabf95698..09c5f451f 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -281,9 +281,9 @@ def find_spot_location( # Get signal along x (raw intensities) x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) - # Compute local min/max for normalization (from sums) - local_min = np.min(x_intensity_profile_raw) - local_max = np.max(x_intensity_profile_raw) + # Compute local min/max for normalization (pixel values) + local_min = float(np.min(cropped_image)) + local_max = float(np.max(cropped_image)) # Determine normalization values: use reference if provided, otherwise local if reference_intensity_min is not None and reference_intensity_max is not None: @@ -294,8 +294,12 @@ def find_spot_location( norm_max = local_max # Normalize intensity profile for peak detection - if norm_max - norm_min > 0: - x_intensity_profile = (x_intensity_profile_raw - norm_min) / (norm_max - norm_min) + # Scale pixel min/max by y_window size since profile values are sums over 2*y_window pixels + y_window_size = 2 * p["y_window"] + profile_norm_min = norm_min * y_window_size + profile_norm_max = norm_max * y_window_size + if profile_norm_max - profile_norm_min > 0: + x_intensity_profile = (x_intensity_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) else: raise ValueError("Cannot normalize: norm_max equals norm_min") @@ -349,10 +353,10 @@ def find_spot_location( else: centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() - # Normalize the centered profile (using same norm_min/norm_max as peak detection) + # Normalize the centered profile (using same scaled values as peak detection) # Avoid division by zero - if norm_max - norm_min > 0: - centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) + if profile_norm_max - profile_norm_min > 0: + centered_profile = (centered_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) else: centered_profile = np.zeros_like(centered_profile_raw) diff --git a/software/control/utils_config.py b/software/control/utils_config.py index fa071a7f4..f9ae42ae8 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -63,12 +63,10 @@ class LaserAFConfig(BaseModel): reference_image: Optional[str] = None # Stores base64 encoded reference image for cross-correlation check reference_image_shape: Optional[tuple] = None reference_image_dtype: Optional[str] = None - reference_crop_min: Optional[float] = None # Min pixel value used for crop normalization - reference_crop_max: Optional[float] = None # Max pixel value used for crop normalization reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check reference_intensity_profile_dtype: Optional[str] = None - reference_intensity_min: Optional[float] = None # Min intensity value used for reference normalization - reference_intensity_max: Optional[float] = None # Max intensity value used for reference normalization + reference_intensity_min: Optional[float] = None # Min pixel value used for reference normalization + reference_intensity_max: Optional[float] = None # Max pixel value used for reference normalization initialize_crop_width: int = 1200 # Width of the center crop used for initialization initialize_crop_height: int = 800 # Height of the center crop used for initialization From 7aa1457ab61b111134155080684bfecea7def2f7 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sat, 10 Jan 2026 22:39:00 -0800 Subject: [PATCH 17/26] revert normalization --- .../core/laser_auto_focus_controller.py | 8 +--- software/control/utils.py | 40 +++++++++---------- software/control/utils_config.py | 4 +- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 253f64da8..ba7e44303 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -553,9 +553,7 @@ def set_reference(self) -> bool: ) reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) - self.reference_crop = (reference_crop - reference_intensity_min) / ( - reference_intensity_max - reference_intensity_min - ) + self.reference_crop = (reference_crop - np.mean(reference_crop)) / np.max(reference_crop) self.reference_intensity_profile = reference_intensity_profile self._log.info( @@ -662,9 +660,7 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: y_end = min(current_image.shape[0], center_y + self.laser_af_properties.spot_crop_size // 2) current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) - ref_min = self.laser_af_properties.reference_intensity_min - ref_max = self.laser_af_properties.reference_intensity_max - current_norm = (current_crop - ref_min) / (ref_max - ref_min) + 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] diff --git a/software/control/utils.py b/software/control/utils.py index 09c5f451f..c4d50a042 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -281,27 +281,15 @@ def find_spot_location( # Get signal along x (raw intensities) x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) - # Compute local min/max for normalization (pixel values) - local_min = float(np.min(cropped_image)) - local_max = float(np.max(cropped_image)) + # Compute local min/max for normalization (from sums) + local_min = np.min(x_intensity_profile_raw) + local_max = np.max(x_intensity_profile_raw) - # Determine normalization values: use reference if provided, otherwise local - if reference_intensity_min is not None and reference_intensity_max is not None: - norm_min = reference_intensity_min - norm_max = reference_intensity_max - else: - norm_min = local_min - norm_max = local_max - - # Normalize intensity profile for peak detection - # Scale pixel min/max by y_window size since profile values are sums over 2*y_window pixels - y_window_size = 2 * p["y_window"] - profile_norm_min = norm_min * y_window_size - profile_norm_max = norm_max * y_window_size - if profile_norm_max - profile_norm_min > 0: - x_intensity_profile = (x_intensity_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) + # Normalize intensity profile for peak detection (always use local normalization) + if local_max - local_min > 0: + x_intensity_profile = (x_intensity_profile_raw - local_min) / (local_max - local_min) else: - raise ValueError("Cannot normalize: norm_max equals norm_min") + raise ValueError("Cannot normalize: local_max equals local_min") # Find all peaks peaks = signal.find_peaks( @@ -353,10 +341,18 @@ def find_spot_location( else: centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() - # Normalize the centered profile (using same scaled values as peak detection) + # Normalize the centered profile + # Use reference min/max if provided (for measurement), otherwise use local (for initialization) + if reference_intensity_min is not None and reference_intensity_max is not None: + norm_min = reference_intensity_min + norm_max = reference_intensity_max + else: + norm_min = local_min + norm_max = local_max + # Avoid division by zero - if profile_norm_max - profile_norm_min > 0: - centered_profile = (centered_profile_raw - profile_norm_min) / (profile_norm_max - profile_norm_min) + if norm_max - norm_min > 0: + centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) else: centered_profile = np.zeros_like(centered_profile_raw) diff --git a/software/control/utils_config.py b/software/control/utils_config.py index f9ae42ae8..fdf130154 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -65,8 +65,8 @@ class LaserAFConfig(BaseModel): reference_image_dtype: Optional[str] = None reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check reference_intensity_profile_dtype: Optional[str] = None - reference_intensity_min: Optional[float] = None # Min pixel value used for reference normalization - reference_intensity_max: Optional[float] = None # Max pixel value used for reference normalization + reference_intensity_min: Optional[float] = None # Min sum value used for profile normalization + reference_intensity_max: Optional[float] = None # Max sum value used for profile normalization initialize_crop_width: int = 1200 # Width of the center crop used for initialization initialize_crop_height: int = 800 # Height of the center crop used for initialization From 5a9678c9d820d8e8a8a163a772eeea6c3704e55b Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 12 Jan 2026 23:10:41 -0800 Subject: [PATCH 18/26] replace line profile spot detection with connected components method - Replace find_spot_location() with connected components algorithm using cv2.connectedComponentsWithStats() for improved robustness - Add new CC parameters: cc_threshold, cc_min_area, cc_max_area, cc_row_tolerance - Remove old line profile parameters and intensity profile RMSE check - Update LaserAFConfig, UI widgets, and settings manager - Simplify return signature to (x, y) tuple - Add debug plot visualization for connected components detection - Update tests for new implementation Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 15 +- .../control/core/laser_af_settings_manager.py | 7 - .../core/laser_auto_focus_controller.py | 146 +------ software/control/utils.py | 360 +++++++++--------- software/control/utils_config.py | 57 +-- software/control/widgets.py | 34 +- .../control/test_spot_detection_manual.py | 24 +- software/tests/control/test_utils.py | 13 +- 8 files changed, 236 insertions(+), 420 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index 7a8ee8358..effc6aef3 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -602,17 +602,12 @@ class SOFTWARE_POS_LIMIT: DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 SPOT_CROP_SIZE = 100 CORRELATION_THRESHOLD = 0.7 -INTENSITY_PROFILE_RMSE_THRESHOLD = ( - 0.2 # Maximum RMSE for intensity profile match (lower is better, profiles normalized 0-1) -) -INTENSITY_PROFILE_HALF_WIDTH = 200 # Half-width of centered intensity profile (total length = 400) PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 -LASER_AF_Y_WINDOW = 96 -LASER_AF_X_WINDOW = 20 -LASER_AF_MIN_PEAK_WIDTH = 10 -LASER_AF_MIN_PEAK_DISTANCE = 10 -LASER_AF_MIN_PEAK_PROMINENCE = 0.20 -LASER_AF_SPOT_SPACING = 100 +# Connected component spot detection parameters +LASER_AF_CC_THRESHOLD = 8 # Intensity threshold for binarization +LASER_AF_CC_MIN_AREA = 5 # Minimum component area in pixels +LASER_AF_CC_MAX_AREA = 5000 # Maximum component area in pixels +LASER_AF_CC_ROW_TOLERANCE = 50 # Allowed deviation from expected row (pixels) SHOW_LEGACY_DISPLACEMENT_MEASUREMENT_WINDOWS = False LASER_AF_FILTER_SIGMA = None LASER_AF_INITIALIZE_CROP_WIDTH = 1200 diff --git a/software/control/core/laser_af_settings_manager.py b/software/control/core/laser_af_settings_manager.py index 068e647ab..3befa472d 100644 --- a/software/control/core/laser_af_settings_manager.py +++ b/software/control/core/laser_af_settings_manager.py @@ -52,9 +52,6 @@ def update_laser_af_settings( objective: str, updates: Dict[str, Any], crop_image: Optional[np.ndarray] = None, - intensity_profile: Optional[np.ndarray] = None, - intensity_min: Optional[float] = None, - intensity_max: Optional[float] = None, ) -> None: if objective not in self.autofocus_configurations: self.autofocus_configurations[objective] = LaserAFConfig(**updates) @@ -63,7 +60,3 @@ def update_laser_af_settings( self.autofocus_configurations[objective] = config.model_copy(update=updates) if crop_image is not None: self.autofocus_configurations[objective].set_reference_image(crop_image) - if intensity_profile is not None: - self.autofocus_configurations[objective].set_reference_intensity_profile( - intensity_profile, intensity_min=intensity_min, intensity_max=intensity_max - ) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index ba7e44303..f395ba268 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -51,14 +51,10 @@ def __init__( self.laser_af_properties = LaserAFConfig() self.reference_crop = None - self.reference_intensity_profile = None # reference intensity profile for CC check self.spot_spacing_pixels = None # spacing between the spots from the two interfaces (unit: pixel) self.image = None # for saving the focus camera image for debugging when centroid cannot be found - self.intensity_profile = None # temporary storage for intensity profile during set_reference - self.intensity_min = None # temporary storage for intensity min during set_reference - self.intensity_max = None # temporary storage for intensity max during set_reference # Load configurations if provided if self.laserAFSettingManager: @@ -81,21 +77,12 @@ def initialize_manual(self, config: LaserAFConfig) -> None: if self.laser_af_properties.has_reference: self.reference_crop = self.laser_af_properties.reference_image_cropped - self.reference_intensity_profile = self.laser_af_properties.reference_intensity_profile_array - - # Invalidate reference if either crop image or intensity profile is missing - if self.reference_crop is None or self.reference_intensity_profile is None: - missing = [] - if self.reference_crop is None: - missing.append("reference image") - if self.reference_intensity_profile is None: - missing.append("intensity profile") - self._log.warning( - f"Loaded laser AF profile is missing {', '.join(missing)}. " "Please re-set reference." - ) + + # Invalidate reference if crop image is missing + if self.reference_crop is None: + self._log.warning("Loaded laser AF profile is missing reference image. Please re-set reference.") self.laser_af_properties = self.laser_af_properties.model_copy(update={"has_reference": False}) self.reference_crop = None - self.reference_intensity_profile = None self.camera.set_region_of_interest( self.laser_af_properties.x_offset, @@ -323,7 +310,7 @@ def finish_with(um: float) -> float: return finish_with(float("nan")) # get laser spot location - result = self._get_laser_spot_centroid(check_intensity_correlation=True) + result = self._get_laser_spot_centroid() if result is not None: # Spot found on first try @@ -396,7 +383,7 @@ def finish_with(um: float) -> float: self._log.info(f"Z search: checking current position {target_pos_um:.1f} um") # Attempt spot detection - result = self._get_laser_spot_centroid(check_intensity_correlation=True) + result = self._get_laser_spot_centroid() if result is None: self._log.info(f"Z search: no valid spot at {target_pos_um:.1f} um") @@ -517,9 +504,6 @@ def set_reference(self) -> bool: # get laser spot location and image result = self._get_laser_spot_centroid() reference_image = self.image - reference_intensity_profile = self.intensity_profile - reference_intensity_min = self.intensity_min - reference_intensity_max = self.intensity_max # turn off the laser try: @@ -533,10 +517,6 @@ def set_reference(self) -> bool: self._log.error("Failed to detect laser spot while setting reference") return False - if reference_intensity_profile is None: - self._log.warning("No intensity profile available for reference") - # Continue anyway - intensity profile is optional for backward compatibility - x, y = result # Store cropped and normalized reference image @@ -554,7 +534,6 @@ def set_reference(self) -> bool: 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.reference_intensity_profile = reference_intensity_profile self._log.info( f"Reference crop updated: shape={self.reference_crop.shape}, " @@ -565,17 +544,11 @@ def set_reference(self) -> bool: self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") self.laser_af_properties = self.laser_af_properties.model_copy(update={"x_reference": x, "has_reference": True}) - # Also update the reference image and intensity profile in laser_af_properties + # Update the reference image in laser_af_properties # so that self.laser_af_properties.reference_image_cropped stays in sync with self.reference_crop self.laser_af_properties.set_reference_image(self.reference_crop) - if reference_intensity_profile is not None: - self.laser_af_properties.set_reference_intensity_profile( - reference_intensity_profile, - intensity_min=reference_intensity_min, - intensity_max=reference_intensity_max, - ) - # Update cached file. reference_crop and intensity_profile need to be saved. + # Update cached file self.laserAFSettingManager.update_laser_af_settings( self.objectiveStore.current_objective, { @@ -583,9 +556,6 @@ def set_reference(self) -> bool: "has_reference": True, }, crop_image=self.reference_crop, - intensity_profile=reference_intensity_profile, - intensity_min=reference_intensity_min, - intensity_max=reference_intensity_max, ) self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) @@ -709,66 +679,6 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: return True, correlation - def _compute_intensity_profile_rmse(self, y: np.ndarray, r: np.ndarray) -> Tuple[float, dict]: - """Compute RMSE between two intensity profiles. - - Args: - y: Current intensity profile - r: Reference intensity profile - - Returns: - Tuple[float, dict]: (rmse, details) where rmse is root mean square error (lower is better) - """ - y = np.asarray(y, float) - r = np.asarray(r, float) - - rmse = float(np.sqrt(np.mean((y - r) ** 2))) - - return rmse, {"rmse": rmse} - - def _check_intensity_profile_match(self, intensity_profile: np.ndarray) -> Tuple[bool, float]: - """Check if current intensity profile matches reference using RMSE score. - - Args: - intensity_profile: Current intensity profile to compare - - Returns: - Tuple[bool, float]: (passed, score) where passed is True if score <= threshold - """ - if self.reference_intensity_profile is None: - self._log.warning("No reference intensity profile available for comparison") - return True, float("nan") # Pass if no reference (backward compatibility) - - # Calculate RMSE-based match score - score, details = self._compute_intensity_profile_rmse( - intensity_profile.ravel(), self.reference_intensity_profile.ravel() - ) - self._log.debug(f"Intensity profile RMSE: {score:.3f}") - - if False: # Set to True to enable debug plot - import matplotlib.pyplot as plt - - fig, ax = plt.subplots(figsize=(10, 4)) - x = np.arange(len(self.reference_intensity_profile)) - ax.plot(x, self.reference_intensity_profile, label="Reference", alpha=0.8) - ax.plot(x, intensity_profile, label="Current", alpha=0.8) - ax.axhline(y=0, color="gray", linestyle="--", alpha=0.3) - ax.set_xlabel("Position (pixels from center)") - ax.set_ylabel("Normalized Intensity") - ax.set_title(f"Intensity Profile Comparison (RMSE score: {score:.3f})") - ax.legend() - plt.tight_layout() - plt.show() - - passed = score <= control._def.INTENSITY_PROFILE_RMSE_THRESHOLD - if not passed: - self._log.warning( - f"Intensity profile RMSE check failed: {score:.3f} > " - f"{control._def.INTENSITY_PROFILE_RMSE_THRESHOLD}" - ) - - return passed, score - def get_new_frame(self): # IMPORTANT: This assumes that the autofocus laser is already on! self.camera.send_trigger(self.camera.get_exposure_time()) @@ -778,7 +688,6 @@ def _get_laser_spot_centroid( self, remove_background: bool = False, use_center_crop: Optional[Tuple[int, int]] = None, - check_intensity_correlation: bool = False, ) -> Optional[Tuple[float, float]]: """Get the centroid location of the laser spot. @@ -788,7 +697,6 @@ def _get_laser_spot_centroid( Args: remove_background: Apply background removal using top-hat filter use_center_crop: (width, height) to crop around center before detection - check_intensity_correlation: If True, verify intensity profile correlation with reference Returns: Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails @@ -799,7 +707,6 @@ def _get_laser_spot_centroid( successful_detections = 0 tmp_x = 0 tmp_y = 0 - intensity_profile = None image = None for i in range(self.laser_af_properties.laser_af_averaging_n): @@ -820,30 +727,19 @@ def _get_laser_spot_centroid( kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (50, 50)) # TODO: tmp hard coded value image = cv2.morphologyEx(image, cv2.MORPH_TOPHAT, kernel) - # calculate centroid + # calculate centroid using connected components parameters spot_detection_params = { - "y_window": self.laser_af_properties.y_window, - "x_window": self.laser_af_properties.x_window, - "peak_width": self.laser_af_properties.min_peak_width, - "peak_distance": self.laser_af_properties.min_peak_distance, - "peak_prominence": self.laser_af_properties.min_peak_prominence, - "spot_spacing": self.laser_af_properties.spot_spacing, + "threshold": self.laser_af_properties.cc_threshold, + "min_area": self.laser_af_properties.cc_min_area, + "max_area": self.laser_af_properties.cc_max_area, + "row_tolerance": self.laser_af_properties.cc_row_tolerance, } - # Pass reference intensity min/max for normalization during measurement - ref_min = None - ref_max = None - if check_intensity_correlation: - ref_min = self.laser_af_properties.reference_intensity_min - ref_max = self.laser_af_properties.reference_intensity_max - result = utils.find_spot_location( image, mode=self.laser_af_properties.spot_detection_mode, params=spot_detection_params, filter_sigma=self.laser_af_properties.filter_sigma, - reference_intensity_min=ref_min, - reference_intensity_max=ref_max, ) if result is None: self._log.warning( @@ -851,8 +747,8 @@ def _get_laser_spot_centroid( ) continue - # Unpack result: (centroid_x, centroid_y, intensity_profile, intensity_min, intensity_max) - spot_x, spot_y, intensity_profile, intensity_min, intensity_max = result + # Unpack result: (centroid_x, centroid_y) + spot_x, spot_y = result if use_center_crop is not None: x, y = ( @@ -891,18 +787,6 @@ def _get_laser_spot_centroid( self._log.error(f"No successful detections") return None - # Perform intensity profile match check if requested - if check_intensity_correlation: - if intensity_profile is not None: - passed, score = self._check_intensity_profile_match(intensity_profile) - if not passed: - return None - else: - # Store the intensity profile and min/max for later use (e.g., when setting reference) - self.intensity_profile = intensity_profile - self.intensity_min = intensity_min - self.intensity_max = intensity_max - # Calculate average position from successful detections x = tmp_x / successful_detections y = tmp_y / successful_detections diff --git a/software/control/utils.py b/software/control/utils.py index c4d50a042..a68bd5cde 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -19,13 +19,10 @@ from typing import Optional, Tuple, List, Callable from control._def import ( - LASER_AF_Y_WINDOW, - LASER_AF_X_WINDOW, - LASER_AF_MIN_PEAK_WIDTH, - LASER_AF_MIN_PEAK_DISTANCE, - LASER_AF_MIN_PEAK_PROMINENCE, - LASER_AF_SPOT_SPACING, - INTENSITY_PROFILE_HALF_WIDTH, + LASER_AF_CC_THRESHOLD, + LASER_AF_CC_MIN_AREA, + LASER_AF_CC_MAX_AREA, + LASER_AF_CC_ROW_TOLERANCE, SpotDetectionMode, FocusMeasureOperator, ) @@ -208,33 +205,23 @@ def find_spot_location( params: Optional[dict] = None, filter_sigma: Optional[int] = None, debug_plot: bool = False, - reference_intensity_min: Optional[float] = None, - reference_intensity_max: Optional[float] = None, -) -> Optional[Tuple[float, float, np.ndarray, float, float]]: - """Find the location of a spot in an image. +) -> Optional[Tuple[float, float]]: + """Find the location of a spot in an image using connected components analysis. 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) + - threshold (float): Intensity threshold for binarization (default: 8) + - min_area (int): Minimum component area in pixels (default: 5) + - max_area (int): Maximum component area in pixels (default: 5000) + - row_tolerance (float): Allowed deviation from expected row in pixels (default: 50) filter_sigma: Sigma for Gaussian filter, or None to skip filtering debug_plot: If True, show debug plots - reference_intensity_min: Min intensity for normalization. If None, use current image's min. - reference_intensity_max: Max intensity for normalization. If None, use current image's max. Returns: - Optional[Tuple[float, float, np.ndarray, float, float]]: (x, y, intensity_profile, intensity_min, intensity_max) - where intensity_profile is a 1D array of length 400 (INTENSITY_PROFILE_HALF_WIDTH * 2) centered - at the detected peak, and intensity_min/max are the values used for normalization. - Returns None if detection fails. + Optional[Tuple[float, float]]: (x, y) coordinates of spot centroid, or None if detection fails. Raises: ValueError: If image is invalid or mode is incompatible with detected spots @@ -243,15 +230,15 @@ def find_spot_location( if image is None or not isinstance(image, np.ndarray): raise ValueError("Invalid input image") - # Default parameters + if image.size == 0: + raise ValueError("Invalid input image") + + # Default parameters for connected component detection default_params = { - "y_window": LASER_AF_Y_WINDOW, # Half-height of y-axis crop - "x_window": LASER_AF_X_WINDOW, # Half-width of centroid window - "min_peak_width": LASER_AF_MIN_PEAK_WIDTH, # Minimum width of peaks - "min_peak_distance": LASER_AF_MIN_PEAK_DISTANCE, # Minimum distance between peaks - "min_peak_prominence": LASER_AF_MIN_PEAK_PROMINENCE, # Minimum peak prominence - "intensity_threshold": 0.1, # Threshold for intensity filtering - "spot_spacing": LASER_AF_SPOT_SPACING, # Expected spacing between spots + "threshold": LASER_AF_CC_THRESHOLD, + "min_area": LASER_AF_CC_MIN_AREA, + "max_area": LASER_AF_CC_MAX_AREA, + "row_tolerance": LASER_AF_CC_ROW_TOLERANCE, } if params is not None: @@ -260,188 +247,197 @@ def find_spot_location( try: # Apply Gaussian filter if requested + working_image = image.copy() if filter_sigma is not None and filter_sigma > 0: - filtered = gaussian_filter(image.astype(float), sigma=filter_sigma) - image = np.clip(filtered, 0, 255).astype(np.uint8) + filtered = gaussian_filter(working_image.astype(float), sigma=filter_sigma) + working_image = np.clip(filtered, 0, 255).astype(np.uint8) - # Get the y position of the spots - y_intensity_profile = np.sum(image, axis=1) - if np.all(y_intensity_profile == 0): - raise ValueError("No spots detected in image") + # Quick check - if max intensity below threshold, no spot visible + if working_image.max() <= p["threshold"]: + raise ValueError("No spot detected: max intensity below threshold") - peak_y = np.argmax(y_intensity_profile) + # Binarize the image + binary = (working_image > p["threshold"]).astype(np.uint8) - # 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") + # Find connected components + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8) - # Crop along the y axis - cropped_image = image[peak_y - p["y_window"] : peak_y + p["y_window"], :] + # Expected row position (center of image) + expected_row = working_image.shape[0] / 2.0 - # Get signal along x (raw intensities) - x_intensity_profile_raw = np.sum(cropped_image, axis=0).astype(float) + # Filter valid components and collect spot candidates + valid_spots = [] + for i in range(1, num_labels): # Skip background (label 0) + area = stats[i, cv2.CC_STAT_AREA] + cx, cy = centroids[i] - # Compute local min/max for normalization (from sums) - local_min = np.min(x_intensity_profile_raw) - local_max = np.max(x_intensity_profile_raw) + # Size filter + if area < p["min_area"] or area > p["max_area"]: + continue - # Normalize intensity profile for peak detection (always use local normalization) - if local_max - local_min > 0: - x_intensity_profile = (x_intensity_profile_raw - local_min) / (local_max - local_min) - else: - raise ValueError("Cannot normalize: local_max equals local_min") + # Row position filter + if abs(cy - expected_row) > p["row_tolerance"]: + continue + + # Calculate mean intensity of this component for sorting + component_mask = labels == i + intensity = working_image[component_mask].mean() - # Find all peaks - peaks = signal.find_peaks( - 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] + valid_spots.append( + { + "label": i, + "col": cx, + "row": cy, + "area": area, + "intensity": intensity, + "mask": component_mask, + } + ) - if len(peak_locations) == 0: - raise ValueError("No peaks detected") + if len(valid_spots) == 0: + raise ValueError("No valid spots detected after filtering") + + # Sort spots by x-coordinate (column) for mode-based selection + valid_spots.sort(key=lambda s: s["col"]) # 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] + if len(valid_spots) > 1: + raise ValueError(f"Found {len(valid_spots)} spots but expected single spot") + selected_spot = valid_spots[0] elif mode == SpotDetectionMode.DUAL_LEFT: - peak_x = peak_locations[0] + selected_spot = valid_spots[0] # Leftmost + elif mode == SpotDetectionMode.DUAL_RIGHT: + selected_spot = valid_spots[-1] # Rightmost elif mode == SpotDetectionMode.MULTI_RIGHT: - peak_x = peak_locations[-1] + selected_spot = valid_spots[-1] # Rightmost elif mode == SpotDetectionMode.MULTI_SECOND_RIGHT: raise NotImplementedError("MULTI_SECOND_RIGHT is not supported") - # 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}") - # Extract centered intensity profile from raw values (200 pixels on each side of peak) - profile_start = peak_x - INTENSITY_PROFILE_HALF_WIDTH - profile_end = peak_x + INTENSITY_PROFILE_HALF_WIDTH - profile_length = INTENSITY_PROFILE_HALF_WIDTH * 2 - - if profile_start < 0 or profile_end > len(x_intensity_profile_raw): - # Handle edge cases with padding - centered_profile_raw = np.zeros(profile_length, dtype=x_intensity_profile_raw.dtype) - src_start = max(0, profile_start) - src_end = min(len(x_intensity_profile_raw), profile_end) - dst_start = max(0, -profile_start) - dst_end = dst_start + (src_end - src_start) - centered_profile_raw[dst_start:dst_end] = x_intensity_profile_raw[src_start:src_end] - else: - centered_profile_raw = x_intensity_profile_raw[profile_start:profile_end].copy() + # Calculate intensity-weighted centroid for sub-pixel accuracy + component_mask = selected_spot["mask"] + y_coords, x_coords = np.where(component_mask) + intensities = working_image[component_mask].astype(float) - # Normalize the centered profile - # Use reference min/max if provided (for measurement), otherwise use local (for initialization) - if reference_intensity_min is not None and reference_intensity_max is not None: - norm_min = reference_intensity_min - norm_max = reference_intensity_max - else: - norm_min = local_min - norm_max = local_max + # Subtract background (minimum intensity in component) + intensities = intensities - intensities.min() - # Avoid division by zero - if norm_max - norm_min > 0: - centered_profile = (centered_profile_raw - norm_min) / (norm_max - norm_min) + sum_intensity = intensities.sum() + if sum_intensity == 0: + # Fall back to geometric centroid if all intensities are equal + centroid_x = selected_spot["col"] + centroid_y = selected_spot["row"] else: - centered_profile = np.zeros_like(centered_profile_raw) + centroid_x = (x_coords * intensities).sum() / sum_intensity + centroid_y = (y_coords * intensities).sum() / sum_intensity 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 - centroid_x, centroid_y = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) - # Return local min/max (the raw intensity values from this image) - return (centroid_x, centroid_y, centered_profile, local_min, local_max) + _show_connected_components_debug_plot( + working_image, + binary, + labels, + num_labels, + valid_spots, + selected_spot, + centroid_x, + centroid_y, + expected_row, + p, + mode, + ) + + return (centroid_x, centroid_y) except (ValueError, NotImplementedError) as e: raise e except Exception: - # TODO: this should not be a blank Exception catch, we should jsut return None above if we have a valid "no spots" - # case, and let exceptions raise otherwise. - _log.exception(f"Error in spot detection") + _log.exception("Error in spot detection") 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) +def _show_connected_components_debug_plot( + image: np.ndarray, + binary: np.ndarray, + labels: np.ndarray, + num_labels: int, + valid_spots: List[dict], + selected_spot: dict, + centroid_x: float, + centroid_y: float, + expected_row: float, + params: dict, + mode: SpotDetectionMode, +) -> None: + """Show debug visualization for connected components spot detection.""" + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + + # Plot 1: Original image with centroid + ax1 = axes[0, 0] + ax1.imshow(image, cmap="gray") + ax1.axhline(y=expected_row, color="cyan", linestyle="--", alpha=0.5, label="Expected row") + ax1.axhline(y=expected_row - params["row_tolerance"], color="cyan", linestyle=":", alpha=0.3) + ax1.axhline(y=expected_row + params["row_tolerance"], color="cyan", linestyle=":", alpha=0.3) + ax1.plot(centroid_x, centroid_y, "r+", markersize=20, markeredgewidth=2, label="Detected centroid") + ax1.legend(loc="upper right") + ax1.set_title(f"Original Image (threshold={params['threshold']})") + + # Plot 2: Binary mask + ax2 = axes[0, 1] + ax2.imshow(binary, cmap="gray") + ax2.set_title(f"Binary Mask (threshold > {params['threshold']})") + + # Plot 3: Connected components with labels + ax3 = axes[1, 0] + # Create colored label image + colored_labels = np.zeros((*labels.shape, 3), dtype=np.uint8) + colors = plt.cm.tab20(np.linspace(0, 1, max(num_labels, 20))) + for i in range(1, num_labels): + colored_labels[labels == i] = (colors[i % 20, :3] * 255).astype(np.uint8) + ax3.imshow(colored_labels) + # Mark valid spots + for spot in valid_spots: + ax3.plot(spot["col"], spot["row"], "go", markersize=8) + # Mark selected spot + ax3.plot(selected_spot["col"], selected_spot["row"], "r*", markersize=15, label="Selected") + ax3.legend(loc="upper right") + ax3.set_title(f"Connected Components ({num_labels-1} total, {len(valid_spots)} valid)") + + # Plot 4: Zoomed view around selected spot + ax4 = axes[1, 1] + zoom_size = 100 + x_center = int(centroid_x) + y_center = int(centroid_y) + x_start = max(0, x_center - zoom_size) + x_end = min(image.shape[1], x_center + zoom_size) + y_start = max(0, y_center - zoom_size) + y_end = min(image.shape[0], y_center + zoom_size) + zoomed = image[y_start:y_end, x_start:x_end] + ax4.imshow(zoomed, cmap="gray") + # Adjust centroid position for zoomed view + local_cx = centroid_x - x_start + local_cy = centroid_y - y_start + ax4.plot(local_cx, local_cy, "r+", markersize=20, markeredgewidth=2) + # Show component boundary + zoomed_mask = selected_spot["mask"][y_start:y_end, x_start:x_end] + ax4.contour(zoomed_mask, colors="yellow", linewidths=1) + ax4.set_title(f"Zoomed View - Mode: {mode.name}\nCentroid: ({centroid_x:.2f}, {centroid_y:.2f})") + + # Add info text + spot_coords = ", ".join([f"({s['col']:.1f}, {s['row']:.1f})" for s in valid_spots]) + info_text = ( + f"Selected spot: area={selected_spot['area']}, intensity={selected_spot['intensity']:.1f}\n" + f"All valid spots: [{spot_coords}]" + ) + fig.text(0.5, 0.02, info_text, ha="center", fontsize=9, family="monospace") + + plt.tight_layout() + plt.subplots_adjust(bottom=0.1) + plt.show() def get_squid_repo_state_description() -> Optional[str]: diff --git a/software/control/utils_config.py b/software/control/utils_config.py index fdf130154..703b035a3 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -18,12 +18,10 @@ SPOT_CROP_SIZE, CORRELATION_THRESHOLD, PIXEL_TO_UM_CALIBRATION_DISTANCE, - LASER_AF_Y_WINDOW, - LASER_AF_X_WINDOW, - LASER_AF_MIN_PEAK_WIDTH, - LASER_AF_MIN_PEAK_DISTANCE, - LASER_AF_MIN_PEAK_PROMINENCE, - LASER_AF_SPOT_SPACING, + LASER_AF_CC_THRESHOLD, + LASER_AF_CC_MIN_AREA, + LASER_AF_CC_MAX_AREA, + LASER_AF_CC_ROW_TOLERANCE, LASER_AF_FILTER_SIGMA, ) from control._def import SpotDetectionMode @@ -52,21 +50,16 @@ class LaserAFConfig(BaseModel): focus_camera_exposure_time_ms: float = FOCUS_CAMERA_EXPOSURE_TIME_MS focus_camera_analog_gain: float = FOCUS_CAMERA_ANALOG_GAIN spot_detection_mode: SpotDetectionMode = SpotDetectionMode(LASER_AF_SPOT_DETECTION_MODE) - y_window: int = LASER_AF_Y_WINDOW # Half-height of y-axis crop - x_window: int = LASER_AF_X_WINDOW # Half-width of centroid window - min_peak_width: float = LASER_AF_MIN_PEAK_WIDTH # Minimum width of peaks - min_peak_distance: float = LASER_AF_MIN_PEAK_DISTANCE # Minimum distance between peaks - min_peak_prominence: float = LASER_AF_MIN_PEAK_PROMINENCE # Minimum peak prominence - spot_spacing: float = LASER_AF_SPOT_SPACING # Expected spacing between spots + # Connected component spot detection parameters + cc_threshold: float = LASER_AF_CC_THRESHOLD # Intensity threshold for binarization + cc_min_area: int = LASER_AF_CC_MIN_AREA # Minimum component area in pixels + cc_max_area: int = LASER_AF_CC_MAX_AREA # Maximum component area in pixels + cc_row_tolerance: float = LASER_AF_CC_ROW_TOLERANCE # Allowed deviation from expected row filter_sigma: Optional[int] = LASER_AF_FILTER_SIGMA # Sigma for Gaussian filter x_reference: Optional[float] = 0 # Reference position in um reference_image: Optional[str] = None # Stores base64 encoded reference image for cross-correlation check reference_image_shape: Optional[tuple] = None reference_image_dtype: Optional[str] = None - reference_intensity_profile: Optional[str] = None # Base64 encoded 1D intensity profile for CC check - reference_intensity_profile_dtype: Optional[str] = None - reference_intensity_min: Optional[float] = None # Min sum value used for profile normalization - reference_intensity_max: Optional[float] = None # Max sum value used for profile normalization initialize_crop_width: int = 1200 # Width of the center crop used for initialization initialize_crop_height: int = 800 # Height of the center crop used for initialization @@ -78,14 +71,6 @@ def reference_image_cropped(self) -> Optional[np.ndarray]: data = base64.b64decode(self.reference_image.encode("utf-8")) return np.frombuffer(data, dtype=np.dtype(self.reference_image_dtype)).reshape(self.reference_image_shape) - @property - def reference_intensity_profile_array(self) -> Optional[np.ndarray]: - """Convert stored base64 intensity profile data back to numpy array""" - if self.reference_intensity_profile is None: - return None - data = base64.b64decode(self.reference_intensity_profile.encode("utf-8")) - return np.frombuffer(data, dtype=np.dtype(self.reference_intensity_profile_dtype)) - @field_validator("spot_detection_mode", mode="before") @classmethod def validate_spot_detection_mode(cls, v): @@ -107,30 +92,6 @@ def set_reference_image(self, image: Optional[np.ndarray]) -> None: self.reference_image_dtype = str(image.dtype) self.has_reference = True - def set_reference_intensity_profile( - self, - profile: Optional[np.ndarray], - intensity_min: Optional[float] = None, - intensity_max: Optional[float] = None, - ) -> None: - """Convert numpy array to base64 encoded string or clear if None. - - Args: - profile: The intensity profile array, or None to clear - intensity_min: Min intensity value used for normalization - intensity_max: Max intensity value used for normalization - """ - if profile is None: - self.reference_intensity_profile = None - self.reference_intensity_profile_dtype = None - self.reference_intensity_min = None - self.reference_intensity_max = None - return - self.reference_intensity_profile = base64.b64encode(profile.tobytes()).decode("utf-8") - self.reference_intensity_profile_dtype = str(profile.dtype) - self.reference_intensity_min = intensity_min - self.reference_intensity_max = intensity_max - def model_dump(self, serialize=False, **kwargs): """Ensure proper serialization of enums to strings""" data = super().model_dump(**kwargs) diff --git a/software/control/widgets.py b/software/control/widgets.py index 7b79f0f0f..4ee9228e7 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -525,13 +525,11 @@ def init_ui(self): spot_detection_group.setFrameStyle(QFrame.Panel | QFrame.Raised) spot_detection_layout = QVBoxLayout() - # Add spot detection related spinboxes - self._add_spinbox(spot_detection_layout, "Y Window (pixels):", "y_window", 1, 500, 0) - self._add_spinbox(spot_detection_layout, "X Window (pixels):", "x_window", 1, 500, 0) - self._add_spinbox(spot_detection_layout, "Min Peak Width:", "min_peak_width", 1, 100, 1) - self._add_spinbox(spot_detection_layout, "Min Peak Distance:", "min_peak_distance", 1, 100, 1) - self._add_spinbox(spot_detection_layout, "Min Peak Prominence:", "min_peak_prominence", 0.01, 1.0, 2, 0.1) - self._add_spinbox(spot_detection_layout, "Spot Spacing (pixels):", "spot_spacing", 1, 1000, 1) + # Add connected component spot detection related spinboxes + self._add_spinbox(spot_detection_layout, "CC Threshold:", "cc_threshold", 0, 255, 0) + self._add_spinbox(spot_detection_layout, "CC Min Area (pixels):", "cc_min_area", 1, 1000, 0) + self._add_spinbox(spot_detection_layout, "CC Max Area (pixels):", "cc_max_area", 100, 50000, 0) + self._add_spinbox(spot_detection_layout, "CC Row Tolerance (pixels):", "cc_row_tolerance", 1, 200, 0) self._add_spinbox(spot_detection_layout, "Filter Sigma:", "filter_sigma", 0, 100, 1, allow_none=True) # Spot detection mode combo box @@ -684,12 +682,10 @@ def apply_and_initialize(self): "pixel_to_um_calibration_distance": self.spinboxes["pixel_to_um_calibration_distance"].value(), "laser_af_range": self.spinboxes["laser_af_range"].value(), "spot_detection_mode": self.spot_mode_combo.currentData(), - "y_window": int(self.spinboxes["y_window"].value()), - "x_window": int(self.spinboxes["x_window"].value()), - "min_peak_width": self.spinboxes["min_peak_width"].value(), - "min_peak_distance": self.spinboxes["min_peak_distance"].value(), - "min_peak_prominence": self.spinboxes["min_peak_prominence"].value(), - "spot_spacing": self.spinboxes["spot_spacing"].value(), + "cc_threshold": self.spinboxes["cc_threshold"].value(), + "cc_min_area": int(self.spinboxes["cc_min_area"].value()), + "cc_max_area": int(self.spinboxes["cc_max_area"].value()), + "cc_row_tolerance": self.spinboxes["cc_row_tolerance"].value(), "filter_sigma": self.spinboxes["filter_sigma"].value(), "focus_camera_exposure_time_ms": self.exposure_spinbox.value(), "focus_camera_analog_gain": self.analog_gain_spinbox.value(), @@ -751,12 +747,10 @@ def clear_labels(self): def run_spot_detection(self): """Run spot detection with current settings and emit results""" params = { - "y_window": int(self.spinboxes["y_window"].value()), - "x_window": int(self.spinboxes["x_window"].value()), - "min_peak_width": self.spinboxes["min_peak_width"].value(), - "min_peak_distance": self.spinboxes["min_peak_distance"].value(), - "min_peak_prominence": self.spinboxes["min_peak_prominence"].value(), - "spot_spacing": self.spinboxes["spot_spacing"].value(), + "threshold": self.spinboxes["cc_threshold"].value(), + "min_area": int(self.spinboxes["cc_min_area"].value()), + "max_area": int(self.spinboxes["cc_max_area"].value()), + "row_tolerance": self.spinboxes["cc_row_tolerance"].value(), } mode = self.spot_mode_combo.currentData() sigma = self.spinboxes["filter_sigma"].value() @@ -766,7 +760,7 @@ def run_spot_detection(self): try: result = utils.find_spot_location(frame, mode=mode, params=params, filter_sigma=sigma, debug_plot=True) if result is not None: - x, y, _, _, _ = result # Unpack centroid (x, y) and ignore intensity_profile and min/max + x, y = result # Unpack centroid (x, y) self.signal_laser_spot_location.emit(frame, x, y) else: raise Exception("No spot detection result returned") diff --git a/software/tests/control/test_spot_detection_manual.py b/software/tests/control/test_spot_detection_manual.py index d60e44f64..d252a9445 100644 --- a/software/tests/control/test_spot_detection_manual.py +++ b/software/tests/control/test_spot_detection_manual.py @@ -22,21 +22,19 @@ def check_image_from_disk(image_path: str): # Try different detection modes modes = [SpotDetectionMode.SINGLE, SpotDetectionMode.DUAL_LEFT, SpotDetectionMode.DUAL_RIGHT] - # Test parameters to try + # Test parameters to try (connected components parameters) param_sets = [ { - "y_window": 96, - "x_window": 20, - "min_peak_width": 10, - "min_peak_distance": 10, - "min_peak_prominence": 0.25, + "threshold": 8, + "min_area": 5, + "max_area": 5000, + "row_tolerance": 50, }, { - "y_window": 96, - "x_window": 20, - "min_peak_width": 5, - "min_peak_distance": 20, - "min_peak_prominence": 0.25, + "threshold": 15, + "min_area": 10, + "max_area": 3000, + "row_tolerance": 75, }, ] @@ -56,8 +54,8 @@ def check_image_from_disk(image_path: str): ) if result is not None: - x, y, _, intensity_min, intensity_max = result - print(f"Found spot at: ({x:.1f}, {y:.1f}), intensity range: [{intensity_min:.1f}, {intensity_max:.1f}]") + x, y = result + print(f"Found spot at: ({x:.1f}, {y:.1f})") else: print("No spot detected") diff --git a/software/tests/control/test_utils.py b/software/tests/control/test_utils.py index 4d6d78604..41d90236a 100644 --- a/software/tests/control/test_utils.py +++ b/software/tests/control/test_utils.py @@ -45,14 +45,9 @@ def test_single_spot_detection(): result = find_spot_location(image, mode=SpotDetectionMode.SINGLE) assert result is not None - detected_x, detected_y, intensity_profile, intensity_min, intensity_max = result + detected_x, detected_y = result assert abs(detected_x - spot_x) < 5 assert abs(detected_y - spot_y) < 5 - assert intensity_profile is not None - assert len(intensity_profile) == 400 # INTENSITY_PROFILE_HALF_WIDTH * 2 - assert intensity_min is not None - assert intensity_max is not None - assert intensity_max > intensity_min def test_dual_spot_detection(): @@ -63,13 +58,13 @@ def test_dual_spot_detection(): # Test right spot detection result = find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) assert result is not None - detected_x, detected_y, _, _, _ = result + 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 + detected_x, detected_y = result assert abs(detected_x - spots[0][0]) < 5 @@ -81,7 +76,7 @@ def test_multi_spot_detection(): # Test rightmost spot detection result = find_spot_location(image, mode=SpotDetectionMode.MULTI_RIGHT) assert result - detected_x, detected_y, _, _, _ = result + detected_x, detected_y = result assert abs(detected_x - spots[2][0]) < 5 # Test second from right spot detection From 07b4db4477a96859fecfcf330ba141dbcda2592e Mon Sep 17 00:00:00 2001 From: You Yan Date: Tue, 13 Jan 2026 22:32:08 -0800 Subject: [PATCH 19/26] fix spot detection during initialization and search - Add ignore_row_tolerance parameter to _get_laser_spot_centroid() to disable row filtering during initialization when spot location is unknown - Add piezo delay after each move in search loop to allow piezo to settle before attempting spot detection Co-Authored-By: Claude Opus 4.5 --- software/control/core/laser_auto_focus_controller.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index f395ba268..2a09a264b 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -146,6 +146,7 @@ def initialize_auto(self) -> bool: self.laser_af_properties.initialize_crop_width, self.laser_af_properties.initialize_crop_height, ), + ignore_row_tolerance=True, # Spot can be anywhere on full frame during init ) if result is None: self._log.error("Failed to find laser spot during initialization") @@ -379,6 +380,9 @@ def finish_with(um: float) -> float: self._log.info(f"Z search: moving to {target_pos_um:.1f} um (delta: {move_um:+.1f} um)") self._move_z(move_um) current_pos_um = target_pos_um + # Wait for piezo to settle + if self.piezo is not None: + time.sleep(control._def.MULTIPOINT_PIEZO_DELAY_MS / 1000) else: self._log.info(f"Z search: checking current position {target_pos_um:.1f} um") @@ -688,6 +692,7 @@ def _get_laser_spot_centroid( self, remove_background: bool = False, use_center_crop: Optional[Tuple[int, int]] = None, + ignore_row_tolerance: bool = False, ) -> Optional[Tuple[float, float]]: """Get the centroid location of the laser spot. @@ -697,6 +702,7 @@ def _get_laser_spot_centroid( Args: remove_background: Apply background removal using top-hat filter use_center_crop: (width, height) to crop around center before detection + ignore_row_tolerance: If True, disable row tolerance filtering (for initialization) Returns: Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails @@ -728,11 +734,13 @@ def _get_laser_spot_centroid( image = cv2.morphologyEx(image, cv2.MORPH_TOPHAT, kernel) # calculate centroid using connected components parameters + # Use large row_tolerance during initialization when spot location is unknown + row_tolerance = image.shape[0] if ignore_row_tolerance else self.laser_af_properties.cc_row_tolerance spot_detection_params = { "threshold": self.laser_af_properties.cc_threshold, "min_area": self.laser_af_properties.cc_min_area, "max_area": self.laser_af_properties.cc_max_area, - "row_tolerance": self.laser_af_properties.cc_row_tolerance, + "row_tolerance": row_tolerance, } result = utils.find_spot_location( From d56f48edb49a413fe65cea49b3716e59a28a86b3 Mon Sep 17 00:00:00 2001 From: You Yan Date: Thu, 15 Jan 2026 01:31:34 -0800 Subject: [PATCH 20/26] add debug image saving for laser AF characterization mode Save intermediate laser AF camera images when characterization_mode is enabled: - Image after measurement (after search if triggered) - Image used for cross-correlation verification Images saved to /tmp/laser_af_{timestamp}_{step}.bmp Co-Authored-By: Claude Opus 4.5 --- software/control/core/laser_auto_focus_controller.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 2a09a264b..5852dbad2 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -436,9 +436,16 @@ def move_to_target(self, target_um: float) -> bool: else: original_z_um = self.stage.get_pos().z_mm * 1000 + # Debug timestamp for characterization mode + debug_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") if self.characterization_mode else None + current_displacement_um = self.measure_displacement() self._log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") + # Debug: save image after measurement (after search if triggered) + if self.characterization_mode and self.image is not None: + cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_1_measurement.bmp", self.image) + if math.isnan(current_displacement_um): self._log.error("Cannot move to target: failed to measure current displacement") # measure_displacement already restores position on search failure @@ -454,6 +461,11 @@ def move_to_target(self, target_um: float) -> bool: # Verify using cross-correlation that spot is in same location as reference cc_result, correlation = self._verify_spot_alignment() + + # Debug: save image used for cross-correlation verification + if self.characterization_mode and self.image is not None: + cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_2_cc_verify.bmp", self.image) + self.signal_cross_correlation.emit(correlation) if not cc_result: self._log.warning("Cross correlation check failed - spots not well aligned") From 22accfc78a41be196f756d72fb1903a067337042 Mon Sep 17 00:00:00 2001 From: You Yan Date: Fri, 16 Jan 2026 21:09:04 -0800 Subject: [PATCH 21/26] remove redundant 3rd debug image save, fix signal type - Remove _3_after_restore debug image (already saved by acquisition) - Fix failure_return_value type from np.array to float scalar Co-Authored-By: Claude Opus 4.5 --- software/control/core/laser_auto_focus_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 5852dbad2..60adf30d9 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -598,7 +598,7 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: Returns: bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise """ - failure_return_value = False, np.array([0.0, 0.0]) + failure_return_value = False, 0.0 # Get current spot image try: From 8e4e00c9e6868ed8e437aa47f261a7b7255c5da1 Mon Sep 17 00:00:00 2001 From: You Yan Date: Sun, 18 Jan 2026 19:52:39 -0800 Subject: [PATCH 22/26] separate debug_image from image for characterization mode saves - Add self.debug_image for characterization mode (only set on successful detection) - self.image always stores latest captured frame (for error debugging) - Characterization mode saves (_1_measurement.bmp, _2_cc_verify.bmp) now use debug_image - Fixes issue where trigger image was saved instead of successful detection image Co-Authored-By: Claude Opus 4.5 --- .../core/laser_auto_focus_controller.py | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 60adf30d9..1f125a950 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -55,6 +55,7 @@ def __init__( self.spot_spacing_pixels = None # spacing between the spots from the two interfaces (unit: pixel) self.image = None # for saving the focus camera image for debugging when centroid cannot be found + self.debug_image = None # for characterization mode - only stores image on successful detection # Load configurations if provided if self.laserAFSettingManager: @@ -299,6 +300,8 @@ def measure_displacement(self, search_for_spot: bool = True) -> float: Returns: float: Displacement in micrometers, or float('nan') if measurement fails """ + # Reset debug image so characterization mode only saves successful detections + self.debug_image = None def finish_with(um: float) -> float: self.signal_displacement_um.emit(um) @@ -442,9 +445,9 @@ def move_to_target(self, target_um: float) -> bool: current_displacement_um = self.measure_displacement() self._log.info(f"Current laser AF displacement: {current_displacement_um:.1f} μm") - # Debug: save image after measurement (after search if triggered) - if self.characterization_mode and self.image is not None: - cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_1_measurement.bmp", self.image) + # Debug: save image after measurement (only if detection was successful) + if self.characterization_mode and self.debug_image is not None: + cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_1_measurement.bmp", self.debug_image) if math.isnan(current_displacement_um): self._log.error("Cannot move to target: failed to measure current displacement") @@ -462,9 +465,9 @@ def move_to_target(self, target_um: float) -> bool: # Verify using cross-correlation that spot is in same location as reference cc_result, correlation = self._verify_spot_alignment() - # Debug: save image used for cross-correlation verification - if self.characterization_mode and self.image is not None: - cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_2_cc_verify.bmp", self.image) + # Debug: save image used for cross-correlation verification (only if detection was successful) + if self.characterization_mode and self.debug_image is not None: + cv2.imwrite(f"/tmp/laser_af_{debug_timestamp}_2_cc_verify.bmp", self.debug_image) self.signal_cross_correlation.emit(correlation) if not cc_result: @@ -509,6 +512,9 @@ def set_reference(self) -> bool: self._log.error("Laser autofocus is not initialized, cannot set reference") return False + # Reset image so we only use image from successful detection + self.image = None + # turn on the laser try: self.microcontroller.turn_on_AF_laser() @@ -599,6 +605,9 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise """ failure_return_value = False, 0.0 + # Reset image so CC verify saves its own image, not the measurement image + self.image = None + self.debug_image = None # Get current spot image try: @@ -734,7 +743,8 @@ def _get_laser_spot_centroid( self._log.warning(f"Failed to read frame {i + 1}/{self.laser_af_properties.laser_af_averaging_n}") continue - self.image = image # store for debugging # TODO: add to return instead of storing + original_image = image.copy() # Keep copy for debugging + self.image = original_image # Always store latest frame for error debugging full_height, full_width = image.shape[:2] if use_center_crop is not None: @@ -788,6 +798,9 @@ def _get_laser_spot_centroid( ) continue + # Store debug_image only after successful detection (for characterization mode) + self.debug_image = original_image + tmp_x += x tmp_y += y successful_detections += 1 From c634a9b0573ac85f93ac94a4ee484aeed27fb86f Mon Sep 17 00:00:00 2001 From: You Yan Date: Sun, 18 Jan 2026 20:40:28 -0800 Subject: [PATCH 23/26] add aspect ratio filter, update filter sigma default, use pixel-based displacement window - Add cc_max_aspect_ratio parameter (default 2.5) to filter elongated shapes - Change default filter_sigma from None to 1 (enable Gaussian blur) - Rename displacement_success_window_um to displacement_success_window_pixels - Set default displacement window to 300 pixels - Update UI spinboxes for new parameters Co-Authored-By: Claude Opus 4.5 --- software/control/_def.py | 5 +++-- software/control/core/laser_auto_focus_controller.py | 10 +++++++--- software/control/utils.py | 10 ++++++++++ software/control/utils_config.py | 8 +++++--- software/control/widgets.py | 7 +++++-- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/software/control/_def.py b/software/control/_def.py index effc6aef3..edaeb8b1a 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -599,7 +599,7 @@ class SOFTWARE_POS_LIMIT: LASER_AF_CROP_HEIGHT = 256 LASER_AF_SPOT_DETECTION_MODE = SpotDetectionMode.DUAL_LEFT.value LASER_AF_RANGE = 100 -DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 +DISPLACEMENT_SUCCESS_WINDOW_PIXELS = 300 # Max displacement from reference x to accept detection (pixels) SPOT_CROP_SIZE = 100 CORRELATION_THRESHOLD = 0.7 PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 @@ -608,8 +608,9 @@ class SOFTWARE_POS_LIMIT: LASER_AF_CC_MIN_AREA = 5 # Minimum component area in pixels LASER_AF_CC_MAX_AREA = 5000 # Maximum component area in pixels LASER_AF_CC_ROW_TOLERANCE = 50 # Allowed deviation from expected row (pixels) +LASER_AF_CC_MAX_ASPECT_RATIO = 2.5 # Maximum aspect ratio (width/height or height/width) SHOW_LEGACY_DISPLACEMENT_MEASUREMENT_WINDOWS = False -LASER_AF_FILTER_SIGMA = None +LASER_AF_FILTER_SIGMA = 1 # Sigma for Gaussian filter before spot detection LASER_AF_INITIALIZE_CROP_WIDTH = 1200 LASER_AF_INITIALIZE_CROP_HEIGHT = 800 diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 1f125a950..2aeb0023a 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -763,6 +763,7 @@ def _get_laser_spot_centroid( "min_area": self.laser_af_properties.cc_min_area, "max_area": self.laser_af_properties.cc_max_area, "row_tolerance": row_tolerance, + "max_aspect_ratio": self.laser_af_properties.cc_max_aspect_ratio, } result = utils.find_spot_location( @@ -788,13 +789,16 @@ def _get_laser_spot_centroid( else: x, y = spot_x, spot_y + # Check if displacement from reference exceeds the success window (in pixels) if ( self.laser_af_properties.has_reference - and abs(x - self.laser_af_properties.x_reference) * self.laser_af_properties.pixel_to_um - > self.laser_af_properties.laser_af_range + and abs(x - self.laser_af_properties.x_reference) + > self.laser_af_properties.displacement_success_window_pixels ): self._log.warning( - f"Spot detected at ({x:.1f}, {y:.1f}) is out of range ({self.laser_af_properties.laser_af_range:.1f} μm), skipping it." + f"Spot detected at ({x:.1f}, {y:.1f}) is outside displacement window " + f"({abs(x - self.laser_af_properties.x_reference):.1f} > " + f"{self.laser_af_properties.displacement_success_window_pixels:.0f} pixels), skipping it." ) continue diff --git a/software/control/utils.py b/software/control/utils.py index a68bd5cde..e23e2c9b1 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -23,6 +23,7 @@ LASER_AF_CC_MIN_AREA, LASER_AF_CC_MAX_AREA, LASER_AF_CC_ROW_TOLERANCE, + LASER_AF_CC_MAX_ASPECT_RATIO, SpotDetectionMode, FocusMeasureOperator, ) @@ -217,6 +218,7 @@ def find_spot_location( - min_area (int): Minimum component area in pixels (default: 5) - max_area (int): Maximum component area in pixels (default: 5000) - row_tolerance (float): Allowed deviation from expected row in pixels (default: 50) + - max_aspect_ratio (float): Maximum aspect ratio for valid spot (default: 2.5) filter_sigma: Sigma for Gaussian filter, or None to skip filtering debug_plot: If True, show debug plots @@ -239,6 +241,7 @@ def find_spot_location( "min_area": LASER_AF_CC_MIN_AREA, "max_area": LASER_AF_CC_MAX_AREA, "row_tolerance": LASER_AF_CC_ROW_TOLERANCE, + "max_aspect_ratio": LASER_AF_CC_MAX_ASPECT_RATIO, } if params is not None: @@ -270,6 +273,8 @@ def find_spot_location( for i in range(1, num_labels): # Skip background (label 0) area = stats[i, cv2.CC_STAT_AREA] cx, cy = centroids[i] + width = stats[i, cv2.CC_STAT_WIDTH] + height = stats[i, cv2.CC_STAT_HEIGHT] # Size filter if area < p["min_area"] or area > p["max_area"]: @@ -279,6 +284,11 @@ def find_spot_location( if abs(cy - expected_row) > p["row_tolerance"]: continue + # Aspect ratio filter (max of w/h or h/w, so always >= 1) + aspect_ratio = max(width / height, height / width) if height > 0 and width > 0 else float("inf") + if aspect_ratio > p["max_aspect_ratio"]: + continue + # Calculate mean intensity of this component for sorting component_mask = labels == i intensity = working_image[component_mask].mean() diff --git a/software/control/utils_config.py b/software/control/utils_config.py index 703b035a3..0140fadc2 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -14,7 +14,7 @@ LASER_AF_CROP_WIDTH, LASER_AF_CROP_HEIGHT, LASER_AF_SPOT_DETECTION_MODE, - DISPLACEMENT_SUCCESS_WINDOW_UM, + DISPLACEMENT_SUCCESS_WINDOW_PIXELS, SPOT_CROP_SIZE, CORRELATION_THRESHOLD, PIXEL_TO_UM_CALIBRATION_DISTANCE, @@ -22,6 +22,7 @@ LASER_AF_CC_MIN_AREA, LASER_AF_CC_MAX_AREA, LASER_AF_CC_ROW_TOLERANCE, + LASER_AF_CC_MAX_ASPECT_RATIO, LASER_AF_FILTER_SIGMA, ) from control._def import SpotDetectionMode @@ -37,8 +38,8 @@ class LaserAFConfig(BaseModel): pixel_to_um: float = 1 has_reference: bool = False # Track if reference has been set laser_af_averaging_n: int = LASER_AF_AVERAGING_N - displacement_success_window_um: float = ( - DISPLACEMENT_SUCCESS_WINDOW_UM # if the displacement is within this window, we consider the move successful + displacement_success_window_pixels: float = ( + DISPLACEMENT_SUCCESS_WINDOW_PIXELS # Max displacement from reference x to accept detection (pixels) ) spot_crop_size: int = SPOT_CROP_SIZE # Size of region to crop around spot for correlation correlation_threshold: float = CORRELATION_THRESHOLD # Minimum correlation coefficient for valid alignment @@ -55,6 +56,7 @@ class LaserAFConfig(BaseModel): cc_min_area: int = LASER_AF_CC_MIN_AREA # Minimum component area in pixels cc_max_area: int = LASER_AF_CC_MAX_AREA # Maximum component area in pixels cc_row_tolerance: float = LASER_AF_CC_ROW_TOLERANCE # Allowed deviation from expected row + cc_max_aspect_ratio: float = LASER_AF_CC_MAX_ASPECT_RATIO # Maximum aspect ratio for valid spot filter_sigma: Optional[int] = LASER_AF_FILTER_SIGMA # Sigma for Gaussian filter x_reference: Optional[float] = 0 # Reference position in um reference_image: Optional[str] = None # Stores base64 encoded reference image for cross-correlation check diff --git a/software/control/widgets.py b/software/control/widgets.py index 4ee9228e7..7a0c500c4 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -512,7 +512,7 @@ def init_ui(self): # Add threshold property spinboxes self._add_spinbox(settings_layout, "Laser AF Averaging N:", "laser_af_averaging_n", 1, 100, 0) self._add_spinbox( - settings_layout, "Displacement Success Window (μm):", "displacement_success_window_um", 0.1, 10.0, 2 + settings_layout, "Displacement Success Window (pixels):", "displacement_success_window_pixels", 1, 1000, 0 ) self._add_spinbox(settings_layout, "Correlation Threshold:", "correlation_threshold", 0.1, 1.0, 2, 0.1) self._add_spinbox(settings_layout, "Laser AF Range (μm):", "laser_af_range", 1, 1000, 1) @@ -530,6 +530,7 @@ def init_ui(self): self._add_spinbox(spot_detection_layout, "CC Min Area (pixels):", "cc_min_area", 1, 1000, 0) self._add_spinbox(spot_detection_layout, "CC Max Area (pixels):", "cc_max_area", 100, 50000, 0) self._add_spinbox(spot_detection_layout, "CC Row Tolerance (pixels):", "cc_row_tolerance", 1, 200, 0) + self._add_spinbox(spot_detection_layout, "CC Max Aspect Ratio:", "cc_max_aspect_ratio", 1.0, 10.0, 1, 0.5) self._add_spinbox(spot_detection_layout, "Filter Sigma:", "filter_sigma", 0, 100, 1, allow_none=True) # Spot detection mode combo box @@ -686,6 +687,7 @@ def apply_and_initialize(self): "cc_min_area": int(self.spinboxes["cc_min_area"].value()), "cc_max_area": int(self.spinboxes["cc_max_area"].value()), "cc_row_tolerance": self.spinboxes["cc_row_tolerance"].value(), + "cc_max_aspect_ratio": self.spinboxes["cc_max_aspect_ratio"].value(), "filter_sigma": self.spinboxes["filter_sigma"].value(), "focus_camera_exposure_time_ms": self.exposure_spinbox.value(), "focus_camera_analog_gain": self.analog_gain_spinbox.value(), @@ -700,7 +702,7 @@ def apply_and_initialize(self): def update_threshold_settings(self): updates = { "laser_af_averaging_n": int(self.spinboxes["laser_af_averaging_n"].value()), - "displacement_success_window_um": self.spinboxes["displacement_success_window_um"].value(), + "displacement_success_window_pixels": int(self.spinboxes["displacement_success_window_pixels"].value()), "correlation_threshold": self.spinboxes["correlation_threshold"].value(), "laser_af_range": self.spinboxes["laser_af_range"].value(), } @@ -751,6 +753,7 @@ def run_spot_detection(self): "min_area": int(self.spinboxes["cc_min_area"].value()), "max_area": int(self.spinboxes["cc_max_area"].value()), "row_tolerance": self.spinboxes["cc_row_tolerance"].value(), + "max_aspect_ratio": self.spinboxes["cc_max_aspect_ratio"].value(), } mode = self.spot_mode_combo.currentData() sigma = self.spinboxes["filter_sigma"].value() From 26ab57341230067b4f5aa89b2a2f61e5727e990c Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 19 Jan 2026 23:01:09 -0800 Subject: [PATCH 24/26] Add piezo settling delay and Gaussian filtering for CC verification - Add missing piezo delay after _move_z in move_to_target before CC verification - Add missing if self.piezo check for calibration delay (line 219) - Apply Gaussian filter to both reference and current crops in CC comparison when filter_sigma is configured Co-Authored-By: Claude Opus 4.5 --- software/control/core/laser_auto_focus_controller.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index 2aeb0023a..e33fdf90e 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -2,6 +2,7 @@ from typing import Optional, Tuple import cv2 +from scipy.ndimage import gaussian_filter from datetime import datetime import math import numpy as np @@ -215,7 +216,8 @@ def _calibrate_pixel_to_um(self) -> bool: # Move to second position and measure self._move_z(self.laser_af_properties.pixel_to_um_calibration_distance) - time.sleep(control._def.MULTIPOINT_PIEZO_DELAY_MS / 1000) + if self.piezo is not None: + time.sleep(control._def.MULTIPOINT_PIEZO_DELAY_MS / 1000) result = self._get_laser_spot_centroid() if result is None: @@ -461,6 +463,8 @@ def move_to_target(self, target_um: float) -> bool: um_to_move = target_um - current_displacement_um self._move_z(um_to_move) + if self.piezo is not None: + time.sleep(control._def.MULTIPOINT_PIEZO_DELAY_MS / 1000) # Verify using cross-correlation that spot is in same location as reference cc_result, correlation = self._verify_spot_alignment() @@ -555,6 +559,8 @@ def set_reference(self) -> bool: ) reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) + if self.laser_af_properties.filter_sigma is not None and self.laser_af_properties.filter_sigma > 0: + reference_crop = gaussian_filter(reference_crop, sigma=self.laser_af_properties.filter_sigma) self.reference_crop = (reference_crop - np.mean(reference_crop)) / np.max(reference_crop) self._log.info( @@ -655,6 +661,8 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: y_end = min(current_image.shape[0], center_y + self.laser_af_properties.spot_crop_size // 2) current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) + if self.laser_af_properties.filter_sigma is not None and self.laser_af_properties.filter_sigma > 0: + current_crop = gaussian_filter(current_crop, sigma=self.laser_af_properties.filter_sigma) current_norm = (current_crop - np.mean(current_crop)) / np.max(current_crop) # Calculate normalized cross correlation From 718379276b401b3d2565570536832339761f9ae4 Mon Sep 17 00:00:00 2001 From: You Yan Date: Mon, 19 Jan 2026 23:13:10 -0800 Subject: [PATCH 25/26] Fix KeyError for displacement_success_window_um in widgets.py Changed spinbox key from displacement_success_window_um to displacement_success_window_pixels to match the created spinbox name. Co-Authored-By: Claude Opus 4.5 --- software/control/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/control/widgets.py b/software/control/widgets.py index 7a0c500c4..5853e21fd 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -677,7 +677,7 @@ def apply_and_initialize(self): updates = { "laser_af_averaging_n": int(self.spinboxes["laser_af_averaging_n"].value()), - "displacement_success_window_um": self.spinboxes["displacement_success_window_um"].value(), + "displacement_success_window_pixels": int(self.spinboxes["displacement_success_window_pixels"].value()), "spot_crop_size": int(self.spinboxes["spot_crop_size"].value()), "correlation_threshold": self.spinboxes["correlation_threshold"].value(), "pixel_to_um_calibration_distance": self.spinboxes["pixel_to_um_calibration_distance"].value(), From cd465946536f645f60f1383da6bdaaf8df4851dc Mon Sep 17 00:00:00 2001 From: You Yan Date: Wed, 21 Jan 2026 00:41:26 -0800 Subject: [PATCH 26/26] Fix CC verification to crop around reference position instead of detected peak Previously, cross-correlation verification cropped around the detected spot position, which allowed debris/contamination spots to pass the CC check if their shape was consistent. Now we crop around the reference position, so off-position spots will appear off-center in the crop, resulting in low correlation and failing the check. Also added warning log when detected spot is >20 pixels from reference, indicating possible debris/contamination. Co-Authored-By: Claude Opus 4.5 --- .../core/laser_auto_focus_controller.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index e33fdf90e..54a9d7ddd 100644 --- a/software/control/core/laser_auto_focus_controller.py +++ b/software/control/core/laser_auto_focus_controller.py @@ -650,11 +650,21 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: self._log.error("Failed to detect spot centroid for cross-correlation check") return failure_return_value - # Crop current image around the detected peak (not the reference position) + # Crop current image around the reference position to detect off-position spots + # If the spot moved to the wrong location (e.g., debris), it will appear off-center + # in this crop, resulting in low correlation and failing the CC check current_peak_x, current_peak_y = centroid_result - center_x = int(current_peak_x) + center_x = int(self.laser_af_properties.x_reference) center_y = int(current_image.shape[0] / 2) + # Log if detected spot is far from reference (potential debris/contamination) + spot_offset = abs(current_peak_x - self.laser_af_properties.x_reference) + if spot_offset > 20: # pixels + self._log.warning( + f"Detected spot at x={current_peak_x:.1f} is {spot_offset:.1f} pixels from reference " + f"x={self.laser_af_properties.x_reference:.1f} - possible debris/contamination" + ) + x_start = max(0, center_x - self.laser_af_properties.spot_crop_size // 2) x_end = min(current_image.shape[1], center_x + self.laser_af_properties.spot_crop_size // 2) y_start = max(0, center_y - self.laser_af_properties.spot_crop_size // 2) @@ -680,9 +690,9 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: axes[0].set_title(f"Reference Crop\n(x={self.laser_af_properties.x_reference:.1f})") axes[0].axis("off") - # Current crop + # Current crop (centered on reference position) axes[1].imshow(current_norm, cmap="gray") - axes[1].set_title(f"Current Crop\n(x={current_peak_x:.1f})") + axes[1].set_title(f"Current Crop @ Reference\n(detected x={current_peak_x:.1f}, crop x={self.laser_af_properties.x_reference:.1f})") axes[1].axis("off") # Difference image