diff --git a/software/control/_def.py b/software/control/_def.py index fe3c5ca0..289ef6d7 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -429,7 +429,7 @@ class SOFTWARE_POS_LIMIT: DEFAULT_MULTIPOINT_NY=1 ENABLE_FLEXIBLE_MULTIPOINT = True -USE_OVERLAP_FOR_FLEXIBLE = False +USE_OVERLAP_FOR_FLEXIBLE = True ENABLE_WELLPLATE_MULTIPOINT = True ENABLE_RECORDING = False diff --git a/software/control/core.py b/software/control/core.py index 7a854248..69c266c5 100644 --- a/software/control/core.py +++ b/software/control/core.py @@ -726,6 +726,7 @@ class NavigationController(QObject): zPos = Signal(float) thetaPos = Signal(float) xyPos = Signal(float,float) + scanGridPos = Signal(float,float) signal_joystick_button_pressed = Signal() # x y z axis pid enable flag @@ -754,10 +755,11 @@ def __init__(self,microcontroller, objectivestore, parent=None): # to be moved to gui for transparency self.microcontroller.set_callback(self.update_pos) - # self.timer_read_pos = QTimer() - # self.timer_read_pos.setInterval(PosUpdate.INTERVAL_MS) - # self.timer_read_pos.timeout.connect(self.update_pos) - # self.timer_read_pos.start() + self.movement_timer = QTimer(self) + self.movement_timer.setInterval(500) # Single 500ms check + self.movement_timer.timeout.connect(self.check_movement_status) + self.movement_timer.setSingleShot(True) # Timer only fires once + self.movement_threshold = 0.0001 # mm # scan start position (obsolete? only for TiledDisplay) self.scan_begin_position_x = 0 @@ -778,7 +780,70 @@ def set_flag_click_to_move(self, flag): def get_flag_click_to_move(self): return self.click_to_move - def scan_preview_move_from_click(self, click_x, click_y, image_width, image_height, Nx=1, Ny=1, dx_mm=0.9, dy_mm=0.9): + def update_pos(self,microcontroller): + # get position from the microcontroller + last_x_pos = self.x_pos_mm + last_y_pos = self.y_pos_mm + x_pos, y_pos, z_pos, theta_pos = microcontroller.get_pos() + self.z_pos = z_pos + # calculate position in mm or rad + if USE_ENCODER_X: + self.x_pos_mm = x_pos*ENCODER_POS_SIGN_X*ENCODER_STEP_SIZE_X_MM + else: + self.x_pos_mm = x_pos*STAGE_POS_SIGN_X*self.get_mm_per_ustep_X() + if USE_ENCODER_Y: + self.y_pos_mm = y_pos*ENCODER_POS_SIGN_Y*ENCODER_STEP_SIZE_Y_MM + else: + self.y_pos_mm = y_pos*STAGE_POS_SIGN_Y*self.get_mm_per_ustep_Y() + if USE_ENCODER_Z: + self.z_pos_mm = z_pos*ENCODER_POS_SIGN_Z*ENCODER_STEP_SIZE_Z_MM + else: + self.z_pos_mm = z_pos*STAGE_POS_SIGN_Z*self.get_mm_per_ustep_Z() + if USE_ENCODER_THETA: + self.theta_pos_rad = theta_pos*ENCODER_POS_SIGN_THETA*ENCODER_STEP_SIZE_THETA + else: + self.theta_pos_rad = theta_pos*STAGE_POS_SIGN_THETA*(2*math.pi/(self.theta_microstepping*FULLSTEPS_PER_REV_THETA)) + + # emit the updated position + self.xPos.emit(self.x_pos_mm) + self.yPos.emit(self.y_pos_mm) + self.zPos.emit(self.z_pos_mm*1000) + self.thetaPos.emit(self.theta_pos_rad*360/(2*math.pi)) + self.xyPos.emit(self.x_pos_mm,self.y_pos_mm) + + if microcontroller.signal_joystick_button_pressed_event: + if self.enable_joystick_button_action: + self.signal_joystick_button_pressed.emit() + print('joystick button pressed') + microcontroller.signal_joystick_button_pressed_event = False + + # Check if position has changed + if last_x_pos != self.x_pos_mm or last_y_pos != self.y_pos_mm: + # restart movement timer + QMetaObject.invokeMethod(self.movement_timer, "start", Qt.QueuedConnection) + + def check_movement_status(self): + """Check if stage has stopped moving after timer delay""" + x_pos, y_pos, z_pos, theta_pos = self.microcontroller.get_pos() + # calculate position in mm or rad + if USE_ENCODER_X: + x_pos_mm = x_pos*ENCODER_POS_SIGN_X*ENCODER_STEP_SIZE_X_MM + else: + x_pos_mm = x_pos*STAGE_POS_SIGN_X*self.get_mm_per_ustep_X() + if USE_ENCODER_Y: + y_pos_mm = y_pos*ENCODER_POS_SIGN_Y*ENCODER_STEP_SIZE_Y_MM + else: + y_pos_mm = y_pos*STAGE_POS_SIGN_Y*self.get_mm_per_ustep_Y() + + delta_x = abs(self.x_pos_mm - x_pos_mm) + delta_y = abs(self.y_pos_mm - y_pos_mm) + + # check if movement less than thresshold (i.e. stopped moving) + if delta_x < self.movement_threshold and delta_y < self.movement_threshold and not self.microcontroller.is_busy(): + # emit pos to draw scan grid + self.scanGridPos.emit(self.x_pos_mm, self.y_pos_mm) + + def scan_preview_move_from_click(self, click_x, click_y, image_width, image_height, Nx=1, Ny=1, dx_mm=0.9, dy_mm=0.9): # obsolete (only for tiled display) """ napariTiledDisplay uses the Nx, Ny, dx_mm, dy_mm fields to move to the correct fov first imageArrayDisplayWindow assumes only a single fov (default values do not impact calculation but this is less correct) @@ -892,40 +957,6 @@ def move_y_to_usteps(self,usteps): def move_z_to_usteps(self,usteps): self.microcontroller.move_z_to_usteps(usteps) - def update_pos(self,microcontroller): - # get position from the microcontroller - x_pos, y_pos, z_pos, theta_pos = microcontroller.get_pos() - self.z_pos = z_pos - # calculate position in mm or rad - if USE_ENCODER_X: - self.x_pos_mm = x_pos*ENCODER_POS_SIGN_X*ENCODER_STEP_SIZE_X_MM - else: - self.x_pos_mm = x_pos*STAGE_POS_SIGN_X*self.get_mm_per_ustep_X() - if USE_ENCODER_Y: - self.y_pos_mm = y_pos*ENCODER_POS_SIGN_Y*ENCODER_STEP_SIZE_Y_MM - else: - self.y_pos_mm = y_pos*STAGE_POS_SIGN_Y*self.get_mm_per_ustep_Y() - if USE_ENCODER_Z: - self.z_pos_mm = z_pos*ENCODER_POS_SIGN_Z*ENCODER_STEP_SIZE_Z_MM - else: - self.z_pos_mm = z_pos*STAGE_POS_SIGN_Z*self.get_mm_per_ustep_Z() - if USE_ENCODER_THETA: - self.theta_pos_rad = theta_pos*ENCODER_POS_SIGN_THETA*ENCODER_STEP_SIZE_THETA - else: - self.theta_pos_rad = theta_pos*STAGE_POS_SIGN_THETA*(2*math.pi/(self.theta_microstepping*FULLSTEPS_PER_REV_THETA)) - # emit the updated position - self.xPos.emit(self.x_pos_mm) - self.yPos.emit(self.y_pos_mm) - self.zPos.emit(self.z_pos_mm*1000) - self.thetaPos.emit(self.theta_pos_rad*360/(2*math.pi)) - self.xyPos.emit(self.x_pos_mm,self.y_pos_mm) - - if microcontroller.signal_joystick_button_pressed_event: - if self.enable_joystick_button_action: - self.signal_joystick_button_pressed.emit() - print('joystick button pressed') - microcontroller.signal_joystick_button_pressed_event = False - def home_x(self): self.microcontroller.home_x() @@ -3524,6 +3555,10 @@ def create_layers(self): self.view.addItem(self.scan_overlay_item) self.view.addItem(self.fov_overlay_item) + self.background_item.setZValue(-1) # Background layer at the bottom + self.scan_overlay_item.setZValue(0) # Scan overlay in the middle + self.fov_overlay_item.setZValue(1) # FOV overlay on top + def update_display_properties(self, sample): if sample == 'glass slide': self.location_update_threshold_mm = 0.2 @@ -3601,29 +3636,19 @@ def update_wellplate_settings(self, sample_format, a1_x_mm, a1_y_mm, a1_x_pixel, self.update_display_properties(sample) self.draw_current_fov(self.x_mm, self.y_mm) - def update_current_location(self, x_mm=None, y_mm=None): + def draw_fov_current_location(self, x_mm=None, y_mm=None): if x_mm is None and y_mm is None: if self.x_mm is None and self.y_mm is None: return - else: - self.draw_current_fov(self.x_mm, self.y_mm) - - elif self.x_mm is not None and self.y_mm is not None: - # update only when the displacement has exceeded certain value - if abs(x_mm - self.x_mm) > self.location_update_threshold_mm or abs(y_mm - self.y_mm) > self.location_update_threshold_mm: - self.draw_current_fov(x_mm, y_mm) - self.x_mm = x_mm - self.y_mm = y_mm - # update_live_scan_grid - if 'glass slide' in self.sample and not self.acquisition_started: - self.signal_update_live_scan_grid.emit(x_mm, y_mm) + self.draw_current_fov(self.x_mm, self.y_mm) else: self.draw_current_fov(x_mm, y_mm) self.x_mm = x_mm self.y_mm = y_mm - # update_live_scan_grid - if 'glass slide' in self.sample and not self.acquisition_started: - self.signal_update_live_scan_grid.emit(x_mm, y_mm) + + def draw_scan_grid(self, x_mm, y_mm): + if 'glass slide' in self.sample and not self.acquisition_started: + self.signal_update_live_scan_grid.emit(x_mm, y_mm) def get_FOV_pixel_coordinates(self, x_mm, y_mm): if self.sample == 'glass slide': diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index fdda3e4d..7107eb18 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -663,7 +663,9 @@ def makeConnections(self): self.objectivesWidget.signal_objective_changed.connect(self.navigationViewer.on_objective_changed) if ENABLE_FLEXIBLE_MULTIPOINT: self.objectivesWidget.signal_objective_changed.connect(self.flexibleMultiPointWidget.update_fov_positions) - self.navigationController.xyPos.connect(self.navigationViewer.update_current_location) + self.navigationController.xyPos.connect(self.navigationViewer.draw_fov_current_location) + if WELLPLATE_FORMAT == 'glass slide': + self.navigationController.scanGridPos.connect(self.navigationViewer.draw_scan_grid) self.multipointController.signal_register_current_fov.connect(self.navigationViewer.register_fov) self.multipointController.signal_current_configuration.connect(self.liveControlWidget.set_microscope_mode) self.multipointController.signal_z_piezo_um.connect(self.piezoWidget.update_displacement_um_display) @@ -704,7 +706,7 @@ def makeConnections(self): if ENABLE_WELLPLATE_MULTIPOINT: self.wellSelectionWidget.signal_wellSelected.connect(self.wellplateMultiPointWidget.set_well_coordinates) self.objectivesWidget.signal_objective_changed.connect(self.wellplateMultiPointWidget.update_coordinates) - self.wellplateMultiPointWidget.signal_update_navigation_viewer.connect(self.navigationViewer.update_current_location) + self.wellplateMultiPointWidget.signal_update_navigation_viewer.connect(self.navigationViewer.draw_fov_current_location) if SUPPORT_LASER_AUTOFOCUS: self.liveControlWidget_focus_camera.signal_newExposureTime.connect(self.cameraSettingWidget_focus_camera.set_exposure_time) @@ -916,11 +918,13 @@ def onWellplateChanged(self, format_): self.toggleWellSelector(False) self.multipointController.inverted_objective = False self.navigationController.inverted_objective = False + self.navigationController.scanGridPos.connect(self.navigationViewer.draw_scan_grid) self.setupSlidePositionController(is_for_wellplate=False) else: self.toggleWellSelector(True) self.multipointController.inverted_objective = True self.navigationController.inverted_objective = True + self.navigationController.scanGridPos.disconnect(self.navigationViewer.draw_scan_grid) self.setupSlidePositionController(is_for_wellplate=True) if format_ == '1536 well plate': diff --git a/software/control/widgets.py b/software/control/widgets.py index 44d26f37..ebaa5255 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -3123,6 +3123,10 @@ def add_location(self): # Check for duplicates using rounded values for comparison if not np.any(np.all(self.location_list[:, :2] == [round(x,3), round(y,3)], axis=1)): + # Block signals to prevent triggering cell_was_changed + self.table_location_list.blockSignals(True) + self.dropdown_location_list.blockSignals(True) + # Store actual values in location_list self.location_list = np.vstack((self.location_list, [[x, y, z]])) self.location_ids = np.append(self.location_ids, name) @@ -3148,58 +3152,67 @@ def add_location(self): self.region_fov_coordinates_dict[name] = scan_coordinates print(f"Added Region: {name} - x={x}, y={y}, z={z}") + + # Set the current index to the newly added location + self.dropdown_location_list.setCurrentIndex(len(self.location_ids) - 1) + self.table_location_list.selectRow(row) + + # Re-enable signals after the addition is complete + self.table_location_list.blockSignals(False) + self.dropdown_location_list.blockSignals(False) else: print("Duplicate location not added.") def remove_location(self): index = self.dropdown_location_list.currentIndex() if index >= 0: - # Get the region ID + # Remove region ID and associated data region_id = self.location_ids[index] - print("Before Removal:") - print(f"Location IDs: {self.location_ids}") - print(f"Region FOV Coordinates Dict Keys: {list(self.region_fov_coordinates_dict.keys())}") + print(f"Removing region: {region_id}") - # Remove overlays using actual stored coordinates - if region_id in self.region_fov_coordinates_dict: - for coord in self.region_fov_coordinates_dict[region_id]: - self.navigationViewer.deregister_fov_to_image(coord[0], coord[1]) - del self.region_fov_coordinates_dict[region_id] + # Block signals to prevent unintended UI updates + self.table_location_list.blockSignals(True) + self.dropdown_location_list.blockSignals(True) + + # Remove overlays and dictionaries + for coord in self.region_fov_coordinates_dict.pop(region_id, []): + self.navigationViewer.deregister_fov_to_image(coord[0], coord[1]) - # Remove from data structures + self.region_coordinates.pop(region_id, None) self.location_list = np.delete(self.location_list, index, axis=0) self.location_ids = np.delete(self.location_ids, index) - if region_id in self.region_coordinates: - del self.region_coordinates[region_id] - # Update remaining IDs and UI + # Update UI + self.dropdown_location_list.removeItem(index) + self.table_location_list.removeRow(index) + + # Reindex remaining regions and update UI for i in range(index, len(self.location_ids)): old_id = self.location_ids[i] new_id = f'R{i}' self.location_ids[i] = new_id # Update dictionaries - self.region_coordinates[new_id] = self.region_coordinates.pop(old_id) - self.region_fov_coordinates_dict[new_id] = self.region_fov_coordinates_dict.pop(old_id) + self.region_coordinates[new_id] = self.region_coordinates.pop(old_id, None) + self.region_fov_coordinates_dict[new_id] = self.region_fov_coordinates_dict.pop(old_id, []) - # Update UI with rounded display values - self.table_location_list.setItem(i, 3, QTableWidgetItem(new_id)) + # Update UI with new ID and coordinates x, y, z = self.location_list[i] - location_str = f"x:{round(x,3)} mm y:{round(y,3)} mm z:{round(z*1000,1)} μm" + location_str = f"x:{round(x, 3)} mm y:{round(y, 3)} mm z:{round(z * 1000, 1)} μm" self.dropdown_location_list.setItemText(i, location_str) + self.table_location_list.setItem(i, 3, QTableWidgetItem(new_id)) - # Update UI - self.dropdown_location_list.removeItem(index) - self.table_location_list.removeRow(index) - - print("After Removal:") - print(f"Location IDs: {self.location_ids}") - print(f"Region FOV Coordinates Dict Keys: {list(self.region_fov_coordinates_dict.keys())}") + # Re-enable signals + self.table_location_list.blockSignals(False) + self.dropdown_location_list.blockSignals(False) # Clear overlay if no locations remain if len(self.location_list) == 0: self.navigationViewer.clear_overlay() + print(f"Remaining location IDs: {self.location_ids}") + + def create_point_id(self): self.scanCoordinates.get_selected_wells() if len(self.scanCoordinates.name) == 0: @@ -3810,6 +3823,7 @@ def set_default_scan_size(self): self.set_default_shape() print(self.navigationViewer.sample) if 'glass slide' in self.navigationViewer.sample: + self.entry_scan_size.setValue(1.0) # init to 1mm when switching to 'glass slide' self.entry_scan_size.setEnabled(True) self.entry_well_coverage.setEnabled(False) else: