Skip to content

Commit

Permalink
Merge pull request #132 from Alpaca233/laser_af_cache_image
Browse files Browse the repository at this point in the history
save / load laser af reference image as part of cached laser af profile
  • Loading branch information
hongquanli authored Mar 1, 2025
2 parents 399fbb2 + d43905f commit 07c62fc
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 53 deletions.
2 changes: 1 addition & 1 deletion software/control/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,7 +603,7 @@ 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]
image = cv2.imread("tests/data/laser_af_camera.png")[:, :, 0]
height, width = image.shape
return image + np.random.randint(0, 10, size=(height, width), dtype=np.uint8)
"""
Expand Down
52 changes: 26 additions & 26 deletions software/control/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2573,7 +2573,7 @@ def slot_region_progress(self, current_fov, total_fovs):

def validate_acquisition_settings(self) -> bool:
"""Validate settings before starting acquisition"""
if self.do_reflection_af and not self.parent.laserAutofocusController.has_reference:
if self.do_reflection_af and not self.parent.laserAutofocusController.laser_af_properties.has_reference:
QMessageBox.warning(
None,
"Laser Autofocus Not Ready",
Expand Down Expand Up @@ -3740,12 +3740,16 @@ def get_settings_for_objective(self, objective: str) -> Dict[str, Any]:
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]) -> None:
def update_laser_af_settings(
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)
else:
config = self.autofocus_configurations[objective]
self.autofocus_configurations[objective] = config.model_copy(update=updates)
if crop_image is not None:
self.autofocus_configurations[objective].set_reference_image(crop_image)


class ConfigurationManager(QObject):
Expand Down Expand Up @@ -4647,16 +4651,15 @@ def __init__(
self.is_initialized = False

self.laser_af_properties = LaserAFConfig()
self.reference_crop = None

self.x_width = 3088
self.y_width = 2064

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.has_reference = False # Track if reference has been set
self.reference_crop = None # for saving the reference image for cross-correlation check

# Load configurations if provided
if self.laserAFSettingManager:
self.load_cached_configuration()
Expand All @@ -4676,6 +4679,9 @@ def initialize_manual(self, config: LaserAFConfig) -> None:

self.laser_af_properties = adjusted_config

if self.laser_af_properties.has_reference:
self.reference_crop = self.laser_af_properties.reference_image_cropped

self.camera.set_ROI(
self.laser_af_properties.x_offset,
self.laser_af_properties.y_offset,
Expand Down Expand Up @@ -4705,10 +4711,6 @@ def load_cached_configuration(self):
# Initialize with loaded config
self.initialize_manual(config)

# read self.has_reference
self.has_reference = config.has_reference
# TODO: update self.reference_crop

def initialize_auto(self) -> bool:
"""Automatically initialize laser autofocus by finding the spot and calibrating.
Expand Down Expand Up @@ -4743,18 +4745,16 @@ def initialize_auto(self) -> bool:
self.microcontroller.turn_off_AF_laser()
self.microcontroller.wait_till_operation_is_completed()

# Clear reference
self.has_reference = False
self.reference_crop = None

# Set up ROI around spot
# Set up ROI around spot and clear reference
config = self.laser_af_properties.model_copy(
update={
"x_offset": x - self.laser_af_properties.width / 2,
"y_offset": y - self.laser_af_properties.height / 2,
"has_reference": False,
}
)
self.reference_crop = None
config.set_reference_image(None)
self._log.info(f"Laser spot location on the full sensor is ({int(x)}, {int(y)})")

self.initialize_manual(config)
Expand Down Expand Up @@ -4826,10 +4826,11 @@ def _calibrate_pixel_to_um(self) -> bool:
else:
pixel_to_um = self.laser_af_properties.pixel_to_um_calibration_distance / (x1 - x0)
self._log.info(f"Pixel to um conversion factor is {pixel_to_um:.3f} um/pixel")
calibration_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# Update config with new calibration values
self.laser_af_properties = self.laser_af_properties.model_copy(
update={"pixel_to_um": pixel_to_um, "x_reference": (x1 + x0) / 2}
update={"pixel_to_um": pixel_to_um, "calibration_timestamp": calibration_timestamp}
)

# Update cache
Expand Down Expand Up @@ -4882,7 +4883,7 @@ def move_to_target(self, target_um: float) -> bool:
Returns:
bool: True if move was successful, False if measurement failed or displacement was out of range
"""
if not self.has_reference:
if not self.laser_af_properties.has_reference:
self._log.warning("Cannot move to target - reference not set")
return False

Expand Down Expand Up @@ -4966,9 +4967,6 @@ def set_reference(self) -> bool:
return False

x, y = result
self.laser_af_properties = self.laser_af_properties.model_copy(
update={"x_reference": x, "has_reference": self.has_reference}
)

# Store cropped and normalized reference image
center_y = int(reference_image.shape[0] / 2)
Expand All @@ -4983,12 +4981,15 @@ def set_reference(self) -> bool:
self.signal_displacement_um.emit(0)
self._log.info(f"Set reference position to ({x:.1f}, {y:.1f})")

self.has_reference = True
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

# Update cache
# Update cached file. reference_crop needs to be saved.
self.laserAFSettingManager.update_laser_af_settings(
self.objectiveStore.current_objective,
{"x_reference": x + self.laser_af_properties.x_offset, "has_reference": self.has_reference},
{"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)

Expand All @@ -5003,7 +5004,6 @@ def on_settings_changed(self) -> None:
status and loads the cached configuration for the new objective.
"""
self.is_initialized = False
self.has_reference = False
self.load_cached_configuration()

def _verify_spot_alignment(self) -> Tuple[bool, float]:
Expand Down Expand Up @@ -5033,11 +5033,11 @@ def _verify_spot_alignment(self) -> Tuple[bool, float]:

if self.reference_crop is None:
self._log.warning("No reference crop stored")
return False
return False, 0.0

if current_image is None:
self._log.error("Failed to get images for cross-correlation check")
return False
return False, 0.0

# Crop and normalize current image
center_x = int(self.laser_af_properties.x_reference)
Expand All @@ -5059,7 +5059,7 @@ def _verify_spot_alignment(self) -> Tuple[bool, float]:
# Check if correlation exceeds threshold
if correlation < self.laser_af_properties.correlation_threshold:
self._log.warning("Cross correlation check failed - spots not well aligned")
return False
return False, correlation

return True, correlation

Expand Down
30 changes: 28 additions & 2 deletions software/control/utils_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
from pydantic_xml import BaseXmlModel, element, attr
from typing import List, Optional
from pathlib import Path
import base64
import numpy as np

import control.utils_channel as utils_channel
from control._def import (
FOCUS_CAMERA_EXPOSURE_TIME_MS,
Expand Down Expand Up @@ -33,8 +36,7 @@ class LaserAFConfig(BaseModel):
width: int = LASER_AF_CROP_WIDTH
height: int = LASER_AF_CROP_HEIGHT
pixel_to_um: float = 1
has_reference: bool = False
x_reference: float = 0.0
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
Expand All @@ -44,6 +46,7 @@ class LaserAFConfig(BaseModel):
pixel_to_um_calibration_distance: float = (
PIXEL_TO_UM_CALIBRATION_DISTANCE # Distance moved in um during calibration
)
calibration_timestamp: str = "" # Timestamp of calibration performed
laser_af_range: float = LASER_AF_RANGE # Maximum reasonable displacement in um
focus_camera_exposure_time_ms: float = FOCUS_CAMERA_EXPOSURE_TIME_MS
focus_camera_analog_gain: float = FOCUS_CAMERA_ANALOG_GAIN
Expand All @@ -54,6 +57,18 @@ class LaserAFConfig(BaseModel):
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
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

@property
def reference_image_cropped(self) -> Optional[np.ndarray]:
"""Convert stored base64 data back to numpy array"""
if self.reference_image is None:
return None
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)

@field_validator("spot_detection_mode", mode="before")
@classmethod
Expand All @@ -63,6 +78,17 @@ def validate_spot_detection_mode(cls, v):
return SpotDetectionMode(v)
return v

def set_reference_image(self, image: Optional[np.ndarray]) -> None:
"""Convert numpy array to base64 encoded string or clear reference if None"""
if image is None:
self.reference_image = None
self.reference_image_shape = None
self.reference_image_dtype = None
return
self.reference_image = base64.b64encode(image.tobytes()).decode("utf-8")
self.reference_image_shape = image.shape
self.reference_image_dtype = str(image.dtype)

def model_dump(self, serialize=False, **kwargs):
"""Ensure proper serialization of enums to strings"""
data = super().model_dump(**kwargs)
Expand Down
7 changes: 4 additions & 3 deletions software/control/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ def apply_settings(self):
"spot_spacing": self.spinboxes["spot_spacing"].value(),
"focus_camera_exposure_time_ms": self.exposure_spinbox.value(),
"focus_camera_analog_gain": self.analog_gain_spinbox.value(),
"has_reference": False,
}
self.laserAutofocusController.set_laser_af_properties(updates)
self.laserAutofocusController.initialize_auto()
Expand All @@ -596,7 +597,7 @@ def update_calibration_label(self):
# Create and add new calibration label
self.calibration_label = QLabel()
self.calibration_label.setText(
f"Calibration Result: {self.laserAutofocusController.laser_af_properties.pixel_to_um:.3f} pixels/um"
f"Calibration Result: {self.laserAutofocusController.laser_af_properties.pixel_to_um:.3f} pixels/um\nPerformed at {self.laserAutofocusController.laser_af_properties.calibration_timestamp}"
)
self.layout().addWidget(self.calibration_label)

Expand Down Expand Up @@ -6915,8 +6916,8 @@ def init_controller(self):
def update_init_state(self):
self.btn_initialize.setChecked(self.laserAutofocusController.is_initialized)
self.btn_set_reference.setEnabled(self.laserAutofocusController.is_initialized)
self.btn_measure_displacement.setEnabled(self.laserAutofocusController.has_reference)
self.btn_move_to_target.setEnabled(self.laserAutofocusController.has_reference)
self.btn_measure_displacement.setEnabled(self.laserAutofocusController.laser_af_properties.has_reference)
self.btn_move_to_target.setEnabled(self.laserAutofocusController.laser_af_properties.has_reference)

def move_to_target(self, target_um):
self.laserAutofocusController.move_to_target(self.entry_target.value())
Expand Down
12 changes: 9 additions & 3 deletions software/tests/control/gui_test_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ def get_test_live_controller(


def get_test_configuration_manager_path() -> pathlib.Path:
return get_repo_root() / "channel_configurations.xml"
return get_repo_root() / "acquisition_configurations"


def get_test_configuration_manager() -> control.core.core.ConfigurationManager:
return control.core.core.ConfigurationManager(get_test_configuration_manager_path())
channel_manager = control.core.core.ChannelConfigurationManager()
laser_af_manager = control.core.core.LaserAFManager()
return control.core.core.ConfigurationManager(
channel_manager=channel_manager,
laser_af_manager=laser_af_manager,
base_config_path=get_test_configuration_manager_path(),
)


def get_test_illumination_controller(
Expand Down Expand Up @@ -83,7 +89,7 @@ def get_test_multi_point_controller() -> control.core.core.MultiPointController:
microcontroller=microcontroller,
liveController=live_controller,
autofocusController=get_test_autofocus_controller(camera, stage, live_controller, microcontroller),
configurationManager=get_test_configuration_manager(),
configurationManager=config_manager,
scanCoordinates=get_test_scan_coordinates(objective_store, get_test_navigation_viewer(objective_store), stage),
piezo=get_test_piezo_stage(microcontroller),
)
Expand Down
18 changes: 0 additions & 18 deletions software/tests/control/test_gui_config_editors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,6 @@
import control.widgets


def test_config_editor_for_acquisitions_save_to_file(qtbot):
config_editor = control.widgets.ConfigEditorForAcquisitions(
tests.control.gui_test_stubs.get_test_configuration_manager()
)

(good_fd, good_filename) = tempfile.mkstemp()
os.close(good_fd)
assert config_editor.config.write_configuration(good_filename)
os.remove(good_filename)

(bad_fd, bad_filename) = tempfile.mkstemp()
os.close(bad_fd)
read_only_permissions = 0o444
os.chmod(bad_filename, read_only_permissions)

assert not config_editor.config.write_configuration(bad_filename)


def test_config_editor_save_to_file(qtbot):
config_editor = control.widgets.ConfigEditor(ConfigParser())

Expand Down
Loading

0 comments on commit 07c62fc

Please sign in to comment.