diff --git a/software/configurations/configuration_HCS_v2.ini b/software/configurations/configuration_HCS_v2.ini index 0ac51cde..39df1463 100644 --- a/software/configurations/configuration_HCS_v2.ini +++ b/software/configurations/configuration_HCS_v2.ini @@ -155,12 +155,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = MER2-1220-32U3M focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/configurations/configuration_Squid+.ini b/software/configurations/configuration_Squid+.ini index d63846ae..71c7c787 100644 --- a/software/configurations/configuration_Squid+.ini +++ b/software/configurations/configuration_Squid+.ini @@ -153,12 +153,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = MER2-1220-32U3M focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/configurations/configuration_Squid+_H117_ORCA.ini b/software/configurations/configuration_Squid+_H117_ORCA.ini index b136bbbc..3cef4a99 100644 --- a/software/configurations/configuration_Squid+_H117_ORCA.ini +++ b/software/configurations/configuration_Squid+_H117_ORCA.ini @@ -165,12 +165,8 @@ _support_laser_autofocus_options = [True, False] main_camera_model = C15440-20UP focus_camera_model = MER2-630-60U3M focus_camera_exposure_time_ms = 0.8 - -use_glass_top = True -_use_glass_top_options = [True, False] - -has_two_interfaces = True -_has_two_interfaces_options = [True, False] +laser_af_spot_detection_mode = "DUAL_LEFT" +_laser_af_spot_detection_mode_options = [SINGLE, DUAL_LEFT, DUAL_RIGHT, MULTI_RIGHT, MULTI_SECOND_RIGHT] enable_flexible_multipoint = True _enable_flexible_multipoint_options = [True, False] diff --git a/software/control/_def.py b/software/control/_def.py index 32f650ef..e119ded3 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -5,7 +5,7 @@ from configparser import ConfigParser import json import csv - +from control.utils import SpotDetectionMode import squid.logging log = squid.logging.get_logger(__name__) @@ -525,13 +525,12 @@ class SOFTWARE_POS_LIMIT: FOCUS_CAMERA_MODEL = "MER2-630-60U3M" FOCUS_CAMERA_EXPOSURE_TIME_MS = 2 FOCUS_CAMERA_ANALOG_GAIN = 0 -LASER_AF_AVERAGING_N = 5 +LASER_AF_AVERAGING_N = 3 LASER_AF_DISPLAY_SPOT_IMAGE = True LASER_AF_CROP_WIDTH = 1536 LASER_AF_CROP_HEIGHT = 256 -HAS_TWO_INTERFACES = True +LASER_AF_SPOT_DETECTION_MODE = SpotDetectionMode.DUAL_LEFT LASER_AF_RANGE = 200 -USE_GLASS_TOP = True SHOW_LEGACY_DISPLACEMENT_MEASUREMENT_WINDOWS = False MULTIPOINT_REFLECTION_AUTOFOCUS_ENABLE_BY_DEFAULT = False @@ -731,10 +730,10 @@ def load_formats(): DEFAULT_DISPLAY_CROP = Tracking.DEFAULT_DISPLAY_CROP USE_XERYON = False -XERYON_SERIAL_NUMBER = '95130303033351E02050' +XERYON_SERIAL_NUMBER = "95130303033351E02050" XERYON_SPEED = 80 -XERYON_OBJECTIVE_SWITCHER_POS_1 = ['4x', '10x'] -XERYON_OBJECTIVE_SWITCHER_POS_2 = ['20x', '40x', '60x'] +XERYON_OBJECTIVE_SWITCHER_POS_1 = ["4x", "10x"] +XERYON_OBJECTIVE_SWITCHER_POS_2 = ["20x", "40x", "60x"] XERYON_OBJECTIVE_SWITCHER_POS_2_OFFSET_MM = 2 ########################################################## diff --git a/software/control/camera.py b/software/control/camera.py index a7448636..4108e668 100644 --- a/software/control/camera.py +++ b/software/control/camera.py @@ -601,6 +601,12 @@ def send_trigger(self): def read_frame(self): return self.current_frame + ''' + # read from disk for laser af debugging + image = cv2.imread("control/tests/data/laser_af_camera.png")[:, :, 0] + height, width = image.shape + return image + np.random.randint(0, 10, size=(height, width), dtype=np.uint8) + ''' def _on_frame_callback(self, user_param, raw_image): pass diff --git a/software/control/core/core.py b/software/control/core/core.py index 1b9748bd..269a5ee4 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -4454,6 +4454,11 @@ def get_surface_grid(self, x_range, y_range, num_points=50): class LaserAutofocusController(QObject): + DISPLACEMENT_SUCCESS_WINDOW_UM = 1.0 # if the displacement is within this window, we consider the move successful + SPOT_CROP_SIZE = 100 # Size of region to crop around spot for correlation + CORRELATION_THRESHOLD = 0.9 # Minimum correlation coefficient for valid alignment + PIXEL_TO_UM_CALIBRATION_DISTANCE = 6.0 # Distance moved in um during calibration + image_to_display = Signal(np.ndarray) signal_displacement_um = Signal(float) @@ -4463,11 +4468,10 @@ def __init__( camera, liveController, stage: AbstractStage, - has_two_interfaces=True, - use_glass_top=True, look_for_cache=True, ): QObject.__init__(self) + self._log = squid.logging.get_logger(__class__.__name__) self.microcontroller = microcontroller self.camera = camera self.liveController = liveController @@ -4481,8 +4485,6 @@ def __init__( self.x_width = 3088 self.y_width = 2064 - self.has_two_interfaces = has_two_interfaces # e.g. air-glass and glass water, set to false when (1) using oil immersion (2) using 1 mm thick slide (3) using metal coated slide or Si wafer - self.use_glass_top = use_glass_top self.spot_spacing_pixels = None # spacing between the spots from the two interfaces (unit: pixel) self.look_for_cache = look_for_cache @@ -4508,7 +4510,27 @@ def __init__( print(e) pass - def initialize_manual(self, x_offset, y_offset, width, height, pixel_to_um, x_reference, write_to_cache=True): + def initialize_manual( + self, + x_offset: float, + y_offset: float, + width: int, + height: int, + pixel_to_um: float, + x_reference: float, + write_to_cache: bool = True, + ) -> None: + """Initialize laser autofocus with manual parameters. + + Args: + x_offset: X offset for ROI in pixels + y_offset: Y offset for ROI in pixels + width: Width of ROI in pixels + height: Height of ROI in pixels + pixel_to_um: Conversion factor from pixels to micrometers + x_reference: Reference X position in pixels (relative to full sensor) + write_to_cache: Whether to save parameters to cache file + """ cache_string = ",".join( [str(x_offset), str(y_offset), str(width), str(height), str(pixel_to_um), str(x_reference)] ) @@ -4516,244 +4538,365 @@ def initialize_manual(self, x_offset, y_offset, width, height, pixel_to_um, x_re cache_path = Path("cache/laser_af_reference_plane.txt") cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_text(cache_string) + # x_reference is relative to the full sensor self.pixel_to_um = pixel_to_um - self.x_offset = int((x_offset // 8) * 8) - self.y_offset = int((y_offset // 2) * 2) + self.x_offset = int((x_offset // 8) * 8) # Round to multiple of 8 + self.y_offset = int((y_offset // 2) * 2) # Round to multiple of 2 self.width = int((width // 8) * 8) self.height = int((height // 2) * 2) self.x_reference = x_reference - self.x_offset # self.x_reference is relative to the cropped region self.camera.set_ROI(self.x_offset, self.y_offset, self.width, self.height) self.is_initialized = True - def initialize_auto(self): + def initialize_auto(self) -> bool: + """Automatically initialize laser autofocus by finding the spot and calibrating. - # first find the region to crop - # then calculate the convert factor + This method: + 1. Finds the laser spot on full sensor + 2. Sets up ROI around the spot + 3. Calibrates pixel-to-um conversion using two z positions + 4. Sets initial reference position + Returns: + bool: True if initialization successful, False if any step fails + """ # set camera to use full sensor self.camera.set_ROI(0, 0, None, None) # set offset first self.camera.set_ROI(0, 0, 3088, 2064) + # update camera settings self.camera.set_exposure_time(FOCUS_CAMERA_EXPOSURE_TIME_MS) self.camera.set_analog_gain(FOCUS_CAMERA_ANALOG_GAIN) - # turn on the laser + # Find initial spot position self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # get laser spot location - x, y = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + self._log.error("Failed to find laser spot during initialization") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x, y = result - # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # Set up ROI around spot x_offset = x - LASER_AF_CROP_WIDTH / 2 y_offset = y - LASER_AF_CROP_HEIGHT / 2 - print("laser spot location on the full sensor is (" + str(int(x)) + "," + str(int(y)) + ")") + self._log.info(f"Laser spot location on the full sensor is ({int(x)}, {int(y)})") - # set camera crop self.initialize_manual(x_offset, y_offset, LASER_AF_CROP_WIDTH, LASER_AF_CROP_HEIGHT, 1, x) - # turn on laser + # Calibrate pixel-to-um conversion self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # move z to - 6 um + # Move to first position and measure self.stage.move_z(-0.018) - self.stage.move_z(0.012) - time.sleep(0.02) + self.stage.move_z(-0.018+self.PIXEL_TO_UM_CALIBRATION_DISTANCE/1000) - # measure - x0, y0 = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + self._log.error("Failed to find laser spot during calibration (position 1)") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x0, y0 = result - # move z to 6 um - self.stage.move_z(0.006) + # Move to second position and measure + self.stage.move_z(self.PIXEL_TO_UM_CALIBRATION_DISTANCE/1000) time.sleep(0.02) - # measure - x1, y1 = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + if result is None: + self._log.error("Failed to find laser spot during calibration (position 2)") + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + return False + x1, y1 = result - # turn off laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # Calculate conversion factor if x1 - x0 == 0: - # for simulation - self.pixel_to_um = 0.4 + self.pixel_to_um = 0.4 # Simulation value + self._log.warning("Using simulation value for pixel_to_um conversion") else: - # calculate the conversion factor - self.pixel_to_um = 6.0 / (x1 - x0) - print("pixel to um conversion factor is " + str(self.pixel_to_um) + " um/pixel") + self.pixel_to_um = self.PIXEL_TO_UM_CALIBRATION_DISTANCE / (x1 - x0) + self._log.info(f"Pixel to um conversion factor is {self.pixel_to_um:.3f} um/pixel") - # set reference + # Set reference position self.x_reference = x1 + return True - if self.look_for_cache: - cache_path = "cache/laser_af_reference_plane.txt" - try: - x_offset = None - y_offset = None - width = None - height = None - pixel_to_um = None - x_reference = None - with open(cache_path, "r") as cache_file: - for line in cache_file: - value_list = line.split(",") - x_offset = float(value_list[0]) - y_offset = float(value_list[1]) - width = int(value_list[2]) - height = int(value_list[3]) - pixel_to_um = self.pixel_to_um - x_reference = self.x_reference + self.x_offset - break - cache_string = ",".join( - [str(x_offset), str(y_offset), str(width), str(height), str(pixel_to_um), str(x_reference)] - ) - cache_path = Path("cache/laser_af_reference_plane.txt") - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text(cache_string) - except (FileNotFoundError, ValueError, IndexError) as e: - print("Unable to read laser AF state cache, exception below:") - print(e) - pass + def measure_displacement(self) -> float: + """Measure the displacement of the laser spot from the reference position. - def measure_displacement(self): + Returns: + float: Displacement in micrometers, or float('nan') if measurement fails + """ # turn on the laser self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() + # get laser spot location - x, y = self._get_laser_spot_centroid() + result = self._get_laser_spot_centroid() + # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + + if result is None: + self._log.error("Failed to detect laser spot during displacement measurement") + self.signal_displacement_um.emit(float("nan")) # Signal invalid measurement + return float("nan") + + x, y = result # calculate displacement displacement_um = (x - self.x_reference) * self.pixel_to_um self.signal_displacement_um.emit(displacement_um) return displacement_um - def move_to_target(self, target_um): + def move_to_target(self, target_um: float) -> bool: + """Move the stage to reach a target displacement from reference position. + + Args: + target_um: Target displacement in micrometers + + Returns: + bool: True if move was successful, False if measurement failed or displacement was out of range + """ current_displacement_um = self.measure_displacement() - print("Laser AF displacement: ", current_displacement_um) + 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") + return False if abs(current_displacement_um) > LASER_AF_RANGE: - print( - f"Warning: Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" + self._log.warning( + f"Measured displacement ({current_displacement_um:.1f} μm) is unreasonably large, using previous z position" ) - um_to_move = 0 + return False + + um_to_move = target_um - current_displacement_um + self.stage.move_z(um_to_move / 1000) # Convert to mm + + # Verify using cross-correlation that spot is in same location as reference + if not self._verify_spot_alignment(): + self._log.warning("Cross correlation check failed - spots not well aligned") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) + return False + else: + self._log.info("Cross correlation check passed - spots are well aligned") + return True + + """ + # Verify we reached the target + final_displacement = self.measure_displacement() + if math.isnan(final_displacement): + self._log.error("Failed to verify final position") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) + return False + if abs(final_displacement - target_um) > self.DISPLACEMENT_SUCCESS_WINDOW_UM: + self._log.warning(f"Final displacement ({final_displacement:.1f} μm) is out of the success window ({self.DISPLACEMENT_SUCCESS_WINDOW_UM:.1f} μm)") + # move back to the current position + self.stage.move_z(-um_to_move / 1000) + return False else: - um_to_move = target_um - current_displacement_um + self._log.info(f"Final displacement ({final_displacement:.1f} μm) is within the success window ({self.DISPLACEMENT_SUCCESS_WINDOW_UM:.1f} μm)") + return True + """ - self.stage.move_z(um_to_move / 1000) + def set_reference(self) -> bool: + """Set the current spot position as the reference position. - # update the displacement measurement - self.measure_displacement() + Captures and stores both the spot position and a cropped reference image + around the spot for later alignment verification. - def set_reference(self): + Returns: + bool: True if reference was set successfully, False if spot detection failed + """ # turn on the laser self.microcontroller.turn_on_AF_laser() self.microcontroller.wait_till_operation_is_completed() - # get laser spot location - x, y = self._get_laser_spot_centroid() + + # get laser spot location and image + result = self._get_laser_spot_centroid() + reference_image = self.image + # turn off the laser self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() + + if result is None or reference_image is None: + self._log.error("Failed to detect laser spot while setting reference") + return False + + x, y = result self.x_reference = x + + # Store cropped and normalized reference image + center_y = int(reference_image.shape[0] / 2) + x_start = max(0, int(x) - self.SPOT_CROP_SIZE // 2) + x_end = min(reference_image.shape[1], int(x) + self.SPOT_CROP_SIZE // 2) + y_start = max(0, center_y - self.SPOT_CROP_SIZE // 2) + y_end = min(reference_image.shape[0], center_y + self.SPOT_CROP_SIZE // 2) + + reference_crop = reference_image[y_start:y_end, x_start:x_end].astype(np.float32) + self.reference_crop = (reference_crop - np.mean(reference_crop)) / np.max(reference_crop) + self.signal_displacement_um.emit(0) + self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})") + return True - def _caculate_centroid(self, image): - if self.has_two_interfaces == False: - h, w = image.shape - x, y = np.meshgrid(range(w), range(h)) - I = image.astype(float) - I = I - np.amin(I) - I[I / np.amax(I) < 0.2] = 0 - x = np.sum(x * I) / np.sum(I) - y = np.sum(y * I) / np.sum(I) - return x, y - else: - I = image - # get the y position of the spots - tmp = np.sum(I, axis=1) - y0 = np.argmax(tmp) - # crop along the y axis - I = I[y0 - 96 : y0 + 96, :] - # signal along x - tmp = np.sum(I, axis=0) - # find peaks - peak_locations, _ = scipy.signal.find_peaks(tmp, distance=100) - idx = np.argsort(tmp[peak_locations]) - peak_0_location = peak_locations[idx[-1]] - peak_1_location = peak_locations[ - idx[-2] - ] # for air-glass-water, the smaller peak corresponds to the glass-water interface - self.spot_spacing_pixels = peak_1_location - peak_0_location - """ - # find peaks - alternative - if self.spot_spacing_pixels is not None: - peak_locations,_ = scipy.signal.find_peaks(tmp,distance=100) - idx = np.argsort(tmp[peak_locations]) - peak_0_location = peak_locations[idx[-1]] - peak_1_location = peak_locations[idx[-2]] # for air-glass-water, the smaller peak corresponds to the glass-water interface - self.spot_spacing_pixels = peak_1_location-peak_0_location - else: - peak_0_location = np.argmax(tmp) - peak_1_location = peak_0_location + self.spot_spacing_pixels - """ - # choose which surface to use - if self.use_glass_top: - x1 = peak_1_location - else: - x1 = peak_0_location - # find centroid - h, w = I.shape - x, y = np.meshgrid(range(w), range(h)) - I = I[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - x = x[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - y = y[:, max(0, x1 - 64) : min(w - 1, x1 + 64)] - I = I.astype(float) - I = I - np.amin(I) - I[I / np.amax(I) < 0.1] = 0 - x1 = np.sum(x * I) / np.sum(I) - y1 = np.sum(y * I) / np.sum(I) - return x1, y0 - 96 + y1 - - def _get_laser_spot_centroid(self): + def _verify_spot_alignment(self) -> bool: + """Verify laser spot alignment using cross-correlation with reference image. + + Captures current laser spot image and compares it with the reference image + using normalized cross-correlation. Images are cropped around the expected + spot location and normalized by maximum intensity before comparison. + + Returns: + bool: True if spots are well aligned (correlation > CORRELATION_THRESHOLD), False otherwise + """ + # Get current spot image + self.microcontroller.turn_on_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + current_image = self.camera.read_frame() + + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() + + if current_image is None or not hasattr(self, "reference_crop"): + self._log.error("Failed to get images for cross-correlation check") + return False + + # Crop and normalize current image + center_x = int(self.x_reference) + center_y = int(current_image.shape[0] / 2) + + x_start = max(0, center_x - self.SPOT_CROP_SIZE // 2) + x_end = min(current_image.shape[1], center_x + self.SPOT_CROP_SIZE // 2) + y_start = max(0, center_y - self.SPOT_CROP_SIZE // 2) + y_end = min(current_image.shape[0], center_y + self.SPOT_CROP_SIZE // 2) + + current_crop = current_image[y_start:y_end, x_start:x_end].astype(np.float32) + current_norm = (current_crop - np.mean(current_crop)) / np.max(current_crop) + + # Calculate normalized cross correlation + correlation = np.corrcoef(current_norm.ravel(), self.reference_crop.ravel())[0, 1] + + self._log.info(f"Cross correlation with reference: {correlation:.3f}") + + # Check if correlation exceeds threshold + if correlation < self.CORRELATION_THRESHOLD: + self._log.warning("Cross correlation check failed - spots not well aligned") + return False + + return True + + def _get_laser_spot_centroid(self) -> Optional[Tuple[float, float]]: + """Get the centroid location of the laser spot. + + Averages multiple measurements to improve accuracy. The number of measurements + is controlled by LASER_AF_AVERAGING_N. + + Returns: + Optional[Tuple[float, float]]: (x,y) coordinates of spot centroid, or None if detection fails + """ # disable camera callback self.camera.disable_callback() + + successful_detections = 0 tmp_x = 0 tmp_y = 0 + for i in range(LASER_AF_AVERAGING_N): - # send camera trigger - if self.liveController.trigger_mode == TriggerMode.SOFTWARE: - self.camera.send_trigger() - elif self.liveController.trigger_mode == TriggerMode.HARDWARE: - # self.microcontroller.send_hardware_trigger(control_illumination=True,illumination_on_time_us=self.camera.exposure_time*1000) - pass # to edit - # read camera frame - image = self.camera.read_frame() - self.image = image - # optionally display the image - if LASER_AF_DISPLAY_SPOT_IMAGE: - self.image_to_display.emit(image) - # calculate centroid - x, y = self._caculate_centroid(image) - tmp_x = tmp_x + x - tmp_y = tmp_y + y - x = tmp_x / LASER_AF_AVERAGING_N - y = tmp_y / LASER_AF_AVERAGING_N - return x, y - - def get_image(self): + try: + # send camera trigger + if self.liveController.trigger_mode == TriggerMode.SOFTWARE: + self.camera.send_trigger() + elif self.liveController.trigger_mode == TriggerMode.HARDWARE: + # self.microcontroller.send_hardware_trigger(control_illumination=True,illumination_on_time_us=self.camera.exposure_time*1000) + pass # to edit + + # read camera frame + image = self.camera.read_frame() + if image is None: + self._log.warning(f"Failed to read frame {i+1}/{LASER_AF_AVERAGING_N}") + continue + + self.image = image # store for debugging # TODO: add to return instead of storing + + # calculate centroid + result = utils.find_spot_location(image, mode=SPOT_DETECTION_MODE) + if result is None: + self._log.warning(f"No spot detected in frame {i+1}/{LASER_AF_AVERAGING_N}") + continue + + x, y = result + tmp_x += x + tmp_y += y + successful_detections += 1 + + except Exception as e: + self._log.error(f"Error processing frame {i+1}/{LASER_AF_AVERAGING_N}: {str(e)}") + continue + + # optionally display the image + if LASER_AF_DISPLAY_SPOT_IMAGE: + self.image_to_display.emit(image) + + # Check if we got enough successful detections + if successful_detections <= 0: + self._log.error(f"No successful detections") + return None + + # Calculate average position from successful detections + x = tmp_x / successful_detections + y = tmp_y / successful_detections + + self._log.debug(f"Spot centroid found at ({x:.1f}, {y:.1f}) from {successful_detections} detections") + return (x, y) + + def get_image(self) -> Optional[np.ndarray]: + """Capture and display a single image from the laser autofocus camera. + + Turns the laser on, captures an image, displays it, then turns the laser off. + + Returns: + Optional[np.ndarray]: The captured image, or None if capture failed + """ # turn on the laser self.microcontroller.turn_on_AF_laser() - self.microcontroller.wait_till_operation_is_completed() # send trigger, grab image and display image - self.camera.send_trigger() - image = self.camera.read_frame() - self.image_to_display.emit(image) - # turn off the laser - self.microcontroller.turn_off_AF_laser() self.microcontroller.wait_till_operation_is_completed() - return image + + try: + # send trigger, grab image and display image + self.camera.send_trigger() + image = self.camera.read_frame() + + if image is None: + self._log.error("Failed to read frame in get_image") + return None + + self.image_to_display.emit(image) + return image + + except Exception as e: + self._log.error(f"Error capturing image: {str(e)}") + return None + + finally: + # turn off the laser + self.microcontroller.turn_off_AF_laser() + self.microcontroller.wait_till_operation_is_completed() diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 43924a97..81ddff8c 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -83,7 +83,10 @@ import control.camera as camera_fc if USE_XERYON: - from control.objective_changer_2_pos_controller import ObjectiveChanger2PosController, ObjectiveChanger2PosController_Simulation + from control.objective_changer_2_pos_controller import ( + ObjectiveChanger2PosController, + ObjectiveChanger2PosController_Simulation, + ) import control.core.core as core import control.microcontroller as microcontroller @@ -190,9 +193,9 @@ def __init__(self, is_simulation=False, live_only_mode=False, *args, **kwargs): if USE_JUPYTER_CONSOLE: # Create namespace to expose to Jupyter self.namespace = { - 'microscope': self.microscope, + "microscope": self.microscope, } - + # Create Jupyter widget as a dock widget self.jupyter_dock = QDockWidget("Jupyter Console", self) self.jupyter_widget = JupyterWidget(namespace=self.namespace) @@ -293,8 +296,6 @@ def loadObjects(self, is_simulation): self.camera_focus, self.liveController_focus_camera, self.stage, - has_two_interfaces=HAS_TWO_INTERFACES, - use_glass_top=USE_GLASS_TOP, look_for_cache=False, ) @@ -307,7 +308,9 @@ def loadSimulationObjects(self): serial_device=microcontroller.get_microcontroller_serial_device(simulated=True) ) if USE_PRIOR_STAGE: - self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage(sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config()) + self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage( + sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config() + ) else: self.stage: squid.abc.AbstractStage = squid.stage.cephla.CephlaStage( @@ -340,8 +343,9 @@ def loadSimulationObjects(self): if USE_SQUID_FILTERWHEEL: self.squid_filter_wheel = filterwheel.SquidFilterWheelWrapper_Simulation(None) if USE_XERYON: - self.objective_changer = ObjectiveChanger2PosController_Simulation(sn=XERYON_SERIAL_NUMBER,stage=self.stage) - + self.objective_changer = ObjectiveChanger2PosController_Simulation( + sn=XERYON_SERIAL_NUMBER, stage=self.stage + ) def loadHardwareObjects(self): # Initialize hardware objects @@ -356,7 +360,9 @@ def loadHardwareObjects(self): raise if USE_PRIOR_STAGE: - self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage(sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config()) + self.stage: squid.abc.AbstractStage = squid.stage.prior.PriorStage( + sn=PRIOR_STAGE_SN, stage_config=squid.config.get_stage_config() + ) else: self.stage: squid.abc.AbstractStage = squid.stage.cephla.CephlaStage( @@ -452,7 +458,7 @@ def loadHardwareObjects(self): if USE_XERYON: try: - self.objective_changer = ObjectiveChanger2PosController(sn=XERYON_SERIAL_NUMBER,stage=self.stage) + self.objective_changer = ObjectiveChanger2PosController(sn=XERYON_SERIAL_NUMBER, stage=self.stage) except Exception: self.log.error("Error initializing Xeryon objective switcher") raise diff --git a/software/control/tests/data/laser_af_camera.png b/software/control/tests/data/laser_af_camera.png new file mode 100644 index 00000000..0a2c1f7c Binary files /dev/null and b/software/control/tests/data/laser_af_camera.png differ diff --git a/software/control/tests/test_spot_detection_manual.py b/software/control/tests/test_spot_detection_manual.py new file mode 100644 index 00000000..dd751660 --- /dev/null +++ b/software/control/tests/test_spot_detection_manual.py @@ -0,0 +1,68 @@ +import cv2 +import numpy as np +import matplotlib.pyplot as plt +from ..utils import find_spot_location, SpotDetectionMode + + +def test_image_from_disk(image_path: str): + """Test spot detection on a real image file. + + Args: + image_path: Path to the image file to test + """ + # Load image + image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) + if image is None: + raise ValueError(f"Failed to load image from {image_path}") + + print(f"Loaded image shape: {image.shape}") + + # Try different detection modes + modes = [SpotDetectionMode.SINGLE, SpotDetectionMode.DUAL_LEFT, SpotDetectionMode.DUAL_RIGHT] + + # Test parameters to try + param_sets = [ + { + "y_window": 96, + "x_window": 20, + "min_peak_width": 10, + "min_peak_distance": 10, + "min_peak_prominence": 0.25, + }, + { + "y_window": 96, + "x_window": 20, + "min_peak_width": 5, + "min_peak_distance": 20, + "min_peak_prominence": 0.25, + }, + ] + + # Create figure for results + plt.figure(figsize=(15, 10)) + + # Try each mode and parameter set + for i, mode in enumerate(modes): + print(f"\nTesting {mode.name}:") + + for j, params in enumerate(param_sets): + print(f"\nParameters set {j+1}:") + print(params) + + result = find_spot_location(image, mode=mode, params=params, debug_plot=True) + + if result is not None: + x, y = result + print(f"Found spot at: ({x:.1f}, {y:.1f})") + else: + print("No spot detected") + + # Wait for user to review plots + input("Press Enter to continue to next test...") + plt.close("all") + + +if __name__ == "__main__": + # Replace with path to your test image + image_path = "control/tests/data/laser_af_camera.png" + test_image_from_disk(image_path) diff --git a/software/control/tests/test_utils.py b/software/control/tests/test_utils.py new file mode 100644 index 00000000..241b7a97 --- /dev/null +++ b/software/control/tests/test_utils.py @@ -0,0 +1,111 @@ +import numpy as np +import pytest +from ..utils import find_spot_location, SpotDetectionMode + + +def create_test_image(spot_positions, image_size=(480, 640), spot_size=20): + """Create a test image with Gaussian spots at specified positions. + + Args: + spot_positions: List of (x, y) coordinates for spot centers + image_size: Tuple of (height, width) for the image + spot_size: Approximate diameter of each spot + """ + image = np.zeros(image_size) + y, x = np.ogrid[: image_size[0], : image_size[1]] + + for pos_x, pos_y in spot_positions: + spot = np.exp(-((x - pos_x) ** 2 + (y - pos_y) ** 2) / (2 * (spot_size / 4) ** 2)) + image += spot + + # Normalize and convert to uint8 + image = (image * 255 / np.max(image)).astype(np.uint8) + return image + + +def test_single_spot_detection(): + # Create test image with single spot + spot_x, spot_y = 320, 240 + image = create_test_image([(spot_x, spot_y)]) + + # Test detection + result = find_spot_location(image, mode=SpotDetectionMode.SINGLE) + + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spot_x) < 5 + assert abs(detected_y - spot_y) < 5 + + +def test_dual_spot_detection(): + # Create test image with two spots + spots = [(280, 240), (360, 240)] + image = create_test_image(spots) + + # Test right spot detection + result = find_spot_location(image, mode=SpotDetectionMode.DUAL_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[1][0]) < 5 + + # Test left spot detection + result = find_spot_location(image, mode=SpotDetectionMode.DUAL_LEFT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[0][0]) < 5 + + +def test_multi_spot_detection(): + # Create test image with multiple spots + spots = [(200, 240), (280, 240), (360, 240)] + image = create_test_image(spots) + + # Test rightmost spot detection + result = find_spot_location(image, mode=SpotDetectionMode.MULTI_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[2][0]) < 5 + + # Test second from right spot detection + result = find_spot_location(image, mode=SpotDetectionMode.MULTI_SECOND_RIGHT) + assert result is not None + detected_x, detected_y = result + assert abs(detected_x - spots[1][0]) < 5 + + +def test_invalid_inputs(): + # Test empty image + with pytest.raises(ValueError): + find_spot_location(np.zeros((480, 640), dtype=np.uint8)) + + # Test None image + with pytest.raises(ValueError): + find_spot_location(None) + + # Test invalid mode + with pytest.raises(ValueError): + find_spot_location(np.zeros((480, 640), dtype=np.uint8), mode="invalid") + + +def test_spot_detection_parameters(): + # Create test image with single spot + image = create_test_image([(320, 240)]) + + # Test with custom parameters + params = { + "y_window": 50, + "x_window": 15, + "min_peak_width": 5, + "min_peak_distance": 5, + "min_peak_prominence": 0.25, + } + + result = find_spot_location(image, params=params) + assert result is not None + + +def test_debug_plot(tmp_path): + """Test that debug plotting doesn't error.""" + image = create_test_image([(320, 240)]) + # This should create plots but not raise any errors + find_spot_location(image, debug_plot=True) diff --git a/software/control/utils.py b/software/control/utils.py index b457769c..43024470 100644 --- a/software/control/utils.py +++ b/software/control/utils.py @@ -9,7 +9,10 @@ from numpy import std, square, mean import numpy as np from scipy.ndimage import label - +from scipy import signal +import os +from typing import Optional, Tuple +from enum import Enum, auto import squid.logging _log = squid.logging.get_logger("control.utils") @@ -171,7 +174,208 @@ def ensure_directory_exists(raw_string_path: str): path.mkdir(parents=True, exist_ok=True) -def get_squid_repo_state_description() -> Optional[str]: +class SpotDetectionMode(Enum): + """Specifies which spot to detect when multiple spots are present. + + SINGLE: Expect and detect single spot + DUAL_RIGHT: In dual-spot case, use rightmost spot + DUAL_LEFT: In dual-spot case, use leftmost spot + MULTI_RIGHT: In multi-spot case, use rightmost spot + MULTI_SECOND_RIGHT: In multi-spot case, use spot immediately left of rightmost spot + """ + + SINGLE = auto() + DUAL_RIGHT = auto() + DUAL_LEFT = auto() + MULTI_RIGHT = auto() + MULTI_SECOND_RIGHT = auto() + + +def find_spot_location( + image: np.ndarray, + mode: SpotDetectionMode = SpotDetectionMode.SINGLE, + params: Optional[dict] = None, + debug_plot: bool = False, +) -> Optional[Tuple[float, float]]: + """Find the location of a spot in an image. + + Args: + image: Input grayscale image as numpy array + mode: Which spot to detect when multiple spots are present + params: Dictionary of parameters for spot detection. If None, default parameters will be used. + Supported parameters: + - y_window (int): Half-height of y-axis crop (default: 96) + - x_window (int): Half-width of centroid window (default: 20) + - peak_width (int): Minimum width of peaks (default: 10) + - peak_distance (int): Minimum distance between peaks (default: 10) + - peak_prominence (float): Minimum peak prominence (default: 100) + - intensity_threshold (float): Threshold for intensity filtering (default: 0.1) + - spot_spacing (int): Expected spacing between spots for multi-spot modes (default: 100) + + Returns: + Optional[Tuple[float, float]]: (x,y) coordinates of selected spot, or None if detection fails + + Raises: + ValueError: If image is invalid or mode is incompatible with detected spots + """ + # Input validation + if image is None or not isinstance(image, np.ndarray): + raise ValueError("Invalid input image") + + # Default parameters + default_params = { + "y_window": 96, # Half-height of y-axis crop + "x_window": 20, # Half-width of centroid window + "min_peak_width": 10, # Minimum width of peaks + "min_peak_distance": 10, # Minimum distance between peaks + "min_peak_prominence": 0.25, # Minimum peak prominence + "intensity_threshold": 0.1, # Threshold for intensity filtering + "spot_spacing": 100, # Expected spacing between spots + } + + if params is not None: + default_params.update(params) + p = default_params + + try: + # Get the y position of the spots + 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") + + # Handle different spot detection modes + if mode == SpotDetectionMode.SINGLE: + if len(peak_locations) > 1: + raise ValueError(f"Found {len(peak_locations)} peaks but expected single peak") + peak_x = peak_locations[0] + elif mode == SpotDetectionMode.DUAL_RIGHT: + peak_x = peak_locations[-1] + elif mode == SpotDetectionMode.DUAL_LEFT: + peak_x = peak_locations[0] + elif mode == SpotDetectionMode.MULTI_RIGHT: + peak_x = peak_locations[-1] + elif mode == SpotDetectionMode.MULTI_SECOND_RIGHT: + """ + if len(peak_locations) < 2: + raise ValueError("Not enough peaks for MULTI_SECOND_RIGHT mode") + peak_x = peak_locations[-2] + """ + peak_x = _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + peak_x = peak_x - p["spot_spacing"] + else: + raise ValueError(f"Unknown spot detection mode: {mode}") + + if debug_plot: + import matplotlib.pyplot as plt + + fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 8)) + + # Plot original image + ax1.imshow(image, cmap="gray") + ax1.axhline(y=peak_y, color="r", linestyle="--", label="Peak Y") + ax1.axhline(y=peak_y - p["y_window"], color="g", linestyle="--", label="Crop Window") + ax1.axhline(y=peak_y + p["y_window"], color="g", linestyle="--") + ax1.legend() + ax1.set_title("Original Image with Y-crop Lines") + + # Plot Y intensity profile + ax2.plot(y_intensity_profile) + ax2.axvline(x=peak_y, color="r", linestyle="--", label="Peak Y") + ax2.axvline(x=peak_y - p["y_window"], color="g", linestyle="--", label="Crop Window") + ax2.axvline(x=peak_y + p["y_window"], color="g", linestyle="--") + ax2.legend() + ax2.set_title("Y Intensity Profile") + + # Plot X intensity profile and detected peaks + ax3.plot(x_intensity_profile, label="Intensity Profile") + ax3.plot(peak_locations, x_intensity_profile[peak_locations], "x", color="r", label="All Peaks") + + # Plot prominence for all peaks + for peak_idx, prominence in zip(peak_locations, peak_properties["prominences"]): + ax3.vlines( + x=peak_idx, + ymin=x_intensity_profile[peak_idx] - prominence, + ymax=x_intensity_profile[peak_idx], + color="g", + ) + + # Highlight selected peak + ax3.plot(peak_x, x_intensity_profile[peak_x], "o", color="yellow", markersize=10, label="Selected Peak") + ax3.axvline(x=peak_x, color="yellow", linestyle="--", alpha=0.5) + + ax3.legend() + ax3.set_title(f"X Intensity Profile (Mode: {mode.name})") + + plt.tight_layout() + plt.show() + + # Calculate centroid in window around selected peak + return _calculate_spot_centroid(cropped_image, peak_x, peak_y, p) + + except Exception as e: + _log.error(f"Error in spot detection: {str(e)}") + return None + + +def _calculate_spot_centroid(cropped_image: np.ndarray, peak_x: int, peak_y: int, params: dict) -> Tuple[float, float]: + """Calculate precise centroid location in window around peak.""" + h, w = cropped_image.shape + x, y = np.meshgrid(range(w), range(h)) + + # Crop region around the peak + intensity_window = cropped_image[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + x_coords = x[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + y_coords = y[:, peak_x - params["x_window"] : peak_x + params["x_window"]] + + # Process intensity values + intensity_window = intensity_window.astype(float) + intensity_window = intensity_window - np.amin(intensity_window) + if np.amax(intensity_window) > 0: # Avoid division by zero + intensity_window[intensity_window / np.amax(intensity_window) < params["intensity_threshold"]] = 0 + + # Calculate centroid + sum_intensity = np.sum(intensity_window) + if sum_intensity == 0: + raise ValueError("No significant intensity in centroid window") + + centroid_x = np.sum(x_coords * intensity_window) / sum_intensity + centroid_y = np.sum(y_coords * intensity_window) / sum_intensity + + # Convert back to original image coordinates + centroid_y = peak_y - params["y_window"] + centroid_y + + return (centroid_x, centroid_y) + + def get_squid_repo_state_description() -> Optional[str]: # From here: https://stackoverflow.com/a/22881871 def get_script_dir(follow_symlinks=True): if getattr(sys, "frozen", False): # py2exe, PyInstaller, cx_Freeze @@ -187,4 +391,4 @@ def get_script_dir(follow_symlinks=True): return f"{repo.head.object.hexsha} (dirty={repo.is_dirty()})" except git.GitError as e: _log.warning(f"Failed to get script git repo info: {e}") - return None + return None \ No newline at end of file