diff --git a/software/control/_def.py b/software/control/_def.py index 95c986121..edaeb8b1a 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -599,21 +599,25 @@ 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 -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) +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 +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_af_settings_manager.py b/software/control/core/laser_af_settings_manager.py index 558c3fcbe..3befa472d 100644 --- a/software/control/core/laser_af_settings_manager.py +++ b/software/control/core/laser_af_settings_manager.py @@ -48,7 +48,10 @@ 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, ) -> None: if objective not in self.autofocus_configurations: self.autofocus_configurations[objective] = LaserAFConfig(**updates) diff --git a/software/control/core/laser_auto_focus_controller.py b/software/control/core/laser_auto_focus_controller.py index caeecdba5..54a9d7ddd 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 @@ -55,6 +56,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: @@ -78,6 +80,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 + # 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.camera.set_region_of_interest( self.laser_af_properties.x_offset, self.laser_af_properties.y_offset, @@ -140,6 +148,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") @@ -207,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: @@ -268,21 +278,39 @@ 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 _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. + Args: + search_for_spot: If True, search for spot if not found at current position + 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) 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,23 +318,109 @@ 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") - return finish_with(float("nan")) # Signal invalid measurement + 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._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") + + # Attempt spot detection + 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") + 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) + 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. @@ -321,34 +435,66 @@ def move_to_target(self, target_um: float) -> bool: self._log.warning("Cannot move to target - reference not set") return False + # 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 + + # 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 (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") + # 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 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() + + # 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: 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 @@ -370,6 +516,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() @@ -399,24 +548,41 @@ def set_reference(self) -> bool: # 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) + 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( + 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}) + # 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) - # Update cached file. reference_crop needs to be saved. + # Update cached file 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, ) self.laserAFSettingManager.save_configurations(self.objectiveStore.current_objective) @@ -444,7 +610,10 @@ 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 + # 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: @@ -459,7 +628,7 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: 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: @@ -477,16 +646,33 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: self._log.error("Failed to get images for cross-correlation check") return failure_return_value - # Crop and normalize current image + 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 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(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) 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 @@ -494,6 +680,41 @@ def _verify_spot_alignment(self) -> Tuple[bool, np.array]: self._log.info(f"Cross correlation with reference: {correlation:.3f}") + if False: # Set to True to enable 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(f"Reference Crop\n(x={self.laser_af_properties.x_reference:.1f})") + axes[0].axis("off") + + # Current crop (centered on reference position) + axes[1].imshow(current_norm, cmap="gray") + 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 + diff = current_norm - self.reference_crop + axes[2].imshow(diff, cmap="RdBu", vmin=-0.5, vmax=0.5) + 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: {correlation:.3f} (threshold={self.laser_af_properties.correlation_threshold}) [{status}]\n" + f"Peak shift: {peak_diff:.1f} pixels", + fontsize=11, + 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") @@ -507,13 +728,21 @@ def get_new_frame(self): 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, + ignore_row_tolerance: 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 + 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 """ @@ -532,7 +761,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: @@ -543,15 +773,17 @@ 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 + # 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 = { - "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": row_tolerance, + "max_aspect_ratio": self.laser_af_properties.cc_max_aspect_ratio, } + result = utils.find_spot_location( image, mode=self.laser_af_properties.spot_detection_mode, @@ -564,24 +796,33 @@ def _get_laser_spot_centroid( ) continue + # Unpack result: (centroid_x, centroid_y) + spot_x, spot_y = 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 + # 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 + # Store debug_image only after successful detection (for characterization mode) + self.debug_image = original_image + tmp_x += x tmp_y += y successful_detections += 1 diff --git a/software/control/utils.py b/software/control/utils.py index 033102002..e23e2c9b1 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -19,12 +19,11 @@ 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, + LASER_AF_CC_THRESHOLD, + LASER_AF_CC_MIN_AREA, + LASER_AF_CC_MAX_AREA, + LASER_AF_CC_ROW_TOLERANCE, + LASER_AF_CC_MAX_ASPECT_RATIO, SpotDetectionMode, FocusMeasureOperator, ) @@ -208,23 +207,23 @@ def find_spot_location( filter_sigma: Optional[int] = None, debug_plot: bool = False, ) -> Optional[Tuple[float, float]]: - """Find the location of a spot in an image. + """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) + - 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 Returns: - Optional[Tuple[float, float]]: (x,y) coordinates of selected spot, or 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 @@ -233,15 +232,16 @@ 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, + "max_aspect_ratio": LASER_AF_CC_MAX_ASPECT_RATIO, } if params is not None: @@ -250,149 +250,204 @@ 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) - - # 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") - - peak_y = np.argmax(y_intensity_profile) - - # Validate peak_y location - if peak_y < p["y_window"] or peak_y > image.shape[0] - p["y_window"]: - raise ValueError("Spot too close to image edge") - - # Crop along the y axis - cropped_image = image[peak_y - p["y_window"] : peak_y + p["y_window"], :] - - # Get signal along x - x_intensity_profile = np.sum(cropped_image, axis=0) - - # Normalize intensity profile - x_intensity_profile = x_intensity_profile - np.min(x_intensity_profile) - x_intensity_profile = x_intensity_profile / np.max(x_intensity_profile) - - # Find all peaks - peaks = signal.find_peaks( - x_intensity_profile, - width=p["min_peak_width"], - distance=p["min_peak_distance"], - prominence=p["min_peak_prominence"], - ) - peak_locations = peaks[0] - peak_properties = peaks[1] - - if len(peak_locations) == 0: - raise ValueError("No peaks detected") + filtered = gaussian_filter(working_image.astype(float), sigma=filter_sigma) + working_image = np.clip(filtered, 0, 255).astype(np.uint8) + + # 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") + + # Binarize the image + binary = (working_image > p["threshold"]).astype(np.uint8) + + # Find connected components + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8) + + # Expected row position (center of image) + expected_row = working_image.shape[0] / 2.0 + + # 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] + 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"]: + continue + + # Row position filter + 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() + + valid_spots.append( + { + "label": i, + "col": cx, + "row": cy, + "area": area, + "intensity": intensity, + "mask": component_mask, + } + ) + + 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}") + # 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) + + # Subtract background (minimum intensity in component) + intensities = intensities - intensities.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: + 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 - return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + _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 f1dca9b5d..0140fadc2 100644 --- a/software/control/utils_config.py +++ b/software/control/utils_config.py @@ -14,16 +14,15 @@ 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, - 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_CC_MAX_ASPECT_RATIO, LASER_AF_FILTER_SIGMA, ) from control._def import SpotDetectionMode @@ -39,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 @@ -52,12 +51,12 @@ 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 + 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 10f80cfc2..5853e21fd 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) @@ -525,13 +525,12 @@ 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, "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 @@ -678,18 +677,17 @@ 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(), "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(), + "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(), @@ -704,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,12 +749,11 @@ 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(), + "max_aspect_ratio": self.spinboxes["cc_max_aspect_ratio"].value(), } mode = self.spot_mode_combo.currentData() sigma = self.spinboxes["filter_sigma"].value() @@ -766,7 +763,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) 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..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, }, ]