diff --git a/software/control/core/core.py b/software/control/core/core.py index 1d1ca77bb..36a89358e 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -1513,6 +1513,10 @@ def set_autolevel(self, enabled): class NavigationViewer(QFrame): signal_coordinates_clicked = Signal(float, float) # Will emit x_mm, y_mm when clicked + # Colors for the position number labels drawn over the scan grid + POSITION_LABEL_COLOR = (252, 174, 30) # matches the yellow scan grid rectangles + POSITION_CURRENT_COLOR = (0, 200, 255) # cyan - distinct from the red current-FOV box + def __init__(self, objectivestore, camera, sample="glass slide", invertX=False, *args, **kwargs): super().__init__(*args, **kwargs) self._log = squid.logging.get_logger(self.__class__.__name__) @@ -1630,20 +1634,29 @@ def create_layers(self): self.scan_overlay = np.zeros((self.image_height, self.image_width, 4), dtype=np.uint8) self.fov_overlay = np.zeros((self.image_height, self.image_width, 4), dtype=np.uint8) self.focus_point_overlay = np.zeros((self.image_height, self.image_width, 4), dtype=np.uint8) + self.position_highlight_overlay = np.zeros((self.image_height, self.image_width, 4), dtype=np.uint8) self.scan_overlay_item = pg.ImageItem() self.fov_overlay_item = pg.ImageItem() self.focus_point_overlay_item = pg.ImageItem() + self.position_highlight_item = pg.ImageItem() + self.view.addItem(self.scan_overlay_item) self.view.addItem(self.fov_overlay_item) self.view.addItem(self.focus_point_overlay_item) + self.view.addItem(self.position_highlight_item) self.background_item.setZValue(-1) # Background layer at the bottom self.scan_overlay_item.setZValue(0) # Scan overlay in the middle self.focus_point_overlay_item.setZValue(1) # # Focus points next + self.position_highlight_item.setZValue(1.5) # Selected position highlight self.fov_overlay_item.setZValue(2) # FOV overlay on top + # pg.TextItems for the position number labels. load_background_image() calls + # view.clear(), which drops every item, so this list is rebuilt here each time. + self.position_label_items = [] + def update_display_properties(self, sample): if sample == "glass slide": self.location_update_threshold_mm = 0.2 @@ -1842,6 +1855,73 @@ def register_focus_point(self, x_mm, y_mm): cv2.circle(self.focus_point_overlay, (center_x, center_y), radius, color, -1) # -1 thickness means filled self.focus_point_overlay_item.setImage(self.focus_point_overlay) + def set_position_labels(self, labels, current_index=None): + """ + Draw a number label at each listed position. Full rebuild - call when the list changes. + + Args: + labels: list of (x_mm, y_mm, text) tuples + current_index: index into labels to highlight, or None + """ + self.clear_position_labels() + for x_mm, y_mm, text in labels: + current_FOV_top_left, current_FOV_bottom_right = self.get_FOV_pixel_coordinates(x_mm, y_mm) + center_x = (current_FOV_top_left[0] + current_FOV_bottom_right[0]) / 2 + center_y = (current_FOV_top_left[1] + current_FOV_bottom_right[1]) / 2 + # pg.TextItem draws in screen space, so labels stay upright and legible + # regardless of the ViewBox's invertX/invertY or the current zoom level. + item = pg.TextItem(text=text, anchor=(0.5, 0.5)) + item.setZValue(3) + item.setPos(center_x, center_y) + self.view.addItem(item) + self.position_label_items.append(item) + self.set_current_position(current_index) + + def set_current_position(self, index, fov_list=None): + """ + Recolor the existing labels and redraw the highlight box around the selected region. + Cheap compared to set_position_labels - use this on selection changes. + + Args: + index: index of the current position, or None + fov_list: FOVs of the current region to outline, as (x_mm, y_mm[, z_mm]) tuples + or FovCenter objects. None clears the highlight box. + """ + for i, item in enumerate(self.position_label_items): + is_current = i == index + item.setColor(self.POSITION_CURRENT_COLOR if is_current else self.POSITION_LABEL_COLOR) + font = item.textItem.font() + font.setBold(is_current) + item.setFont(font) + + self.position_highlight_overlay.fill(0) + color = (*self.POSITION_CURRENT_COLOR, 255) + for fov in fov_list or []: + # Handle tuple (2D or 3D) and FovCenter object formats + if isinstance(fov, tuple): + x_mm = fov[0] + y_mm = fov[1] + else: + x_mm = fov.x_mm + y_mm = fov.y_mm + current_FOV_top_left, current_FOV_bottom_right = self.get_FOV_pixel_coordinates(x_mm, y_mm) + cv2.rectangle( + self.position_highlight_overlay, + current_FOV_top_left, + current_FOV_bottom_right, + color, + self.box_line_thickness, + ) + self.position_highlight_item.setImage(self.position_highlight_overlay) + + def clear_position_labels(self): + """Remove all position number labels and the selected-position highlight""" + for item in self.position_label_items: + self.view.removeItem(item) + self.position_label_items = [] + self.position_highlight_overlay.fill(0) + self.position_highlight_item.setImage(self.position_highlight_overlay) + def clear_focus_points(self): """Clear just the focus point overlay""" self.focus_point_overlay = np.zeros((self.image_height, self.image_width, 4), dtype=np.uint8) @@ -1857,6 +1937,7 @@ def clear_overlay(self): self.scan_overlay_item.setImage(self.scan_overlay) self.focus_point_overlay.fill(0) self.focus_point_overlay_item.setImage(self.focus_point_overlay) + self.clear_position_labels() def handle_mouse_click(self, evt): if not evt.double(): diff --git a/software/control/widgets.py b/software/control/widgets.py index a08dcf4ce..e2e722938 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -6085,7 +6085,7 @@ def add_components(self): self.dropdown_location_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.btn_add = QPushButton("Add") self.btn_remove = QPushButton("Remove") - self.btn_previous = QPushButton("Previous") + self.btn_previous = QPushButton("Prev") self.btn_next = QPushButton("Next") self.btn_clear = QPushButton("Clear") @@ -6100,7 +6100,8 @@ def add_components(self): self.table_location_list.setColumnCount(4) header_labels = ["x", "y", "z", "ID"] self.table_location_list.setHorizontalHeaderLabels(header_labels) - self.btn_update_z = QPushButton("Update Z") + self.btn_update_xy = QPushButton("Set XY") + self.btn_update_z = QPushButton("Set Z") self.entry_deltaX = QDoubleSpinBox() self.entry_deltaX.setMinimum(0) @@ -6304,15 +6305,25 @@ def add_components(self): temp3.addWidget(QLabel("Location List")) temp3.addWidget(self.dropdown_location_list) self.grid_location_list_line1.addLayout(temp3, 0, 0, 1, 6) # Span across all columns except the last - self.grid_location_list_line1.addWidget(self.btn_update_z, 0, 6, 1, 2) # Align with other buttons + # Set XY / Set Z split the width the single Update Z button used to occupy + self.grid_location_list_line1.addWidget(self.btn_update_xy, 0, 6, 1, 1) + self.grid_location_list_line1.addWidget(self.btn_update_z, 0, 7, 1, 1) self.grid_location_list_line2 = QGridLayout() # Make all buttons span 2 columns for consistent width self.grid_location_list_line2.addWidget(self.btn_add, 1, 0, 1, 2) self.grid_location_list_line2.addWidget(self.btn_remove, 1, 2, 1, 2) - self.grid_location_list_line2.addWidget(self.btn_next, 1, 4, 1, 2) + # Prev / Next split the width the single Next button used to occupy + self.grid_location_list_line2.addWidget(self.btn_previous, 1, 4, 1, 1) + self.grid_location_list_line2.addWidget(self.btn_next, 1, 5, 1, 1) self.grid_location_list_line2.addWidget(self.btn_clear, 1, 6, 1, 2) + # Even column widths, so the split buttons are genuinely half-width instead of + # shrinking to fit their text + for i in range(8): + self.grid_location_list_line1.setColumnStretch(i, 1) + self.grid_location_list_line2.setColumnStretch(i, 1) + self.grid_location_list_line3 = QGridLayout() self.grid_location_list_line3.addWidget(self.btn_import_locations, 2, 0, 1, 3) self.grid_location_list_line3.addWidget(self.btn_export_locations, 2, 3, 1, 3) @@ -6506,6 +6517,7 @@ def setup_connections(self): self.table_location_list.cellClicked.connect(self.cell_was_clicked) self.table_location_list.cellChanged.connect(self.cell_was_changed) self.btn_show_table_location_list.clicked.connect(self.table_location_list.show) + self.btn_update_xy.clicked.connect(self.update_xy) self.btn_update_z.clicked.connect(self.update_z) self.dropdown_location_list.currentIndexChanged.connect(self.go_to) @@ -6615,17 +6627,96 @@ def _reset_reflection_af_reference(self): ): error_dialog("Failed to set reference for reflection autofocus. Is the laser autofocus initialized?") + @staticmethod + def _location_str(x_mm, y_mm, z_mm): + """Single source of truth for the location list display string.""" + return f"x:{round(x_mm,3)} mm y:{round(y_mm,3)} mm z:{round(z_mm * 1000,1)} μm" + + def _selected_location_index(self): + """Current location list index, or None when there is nothing selected.""" + index = self.dropdown_location_list.currentIndex() + if index < 0 or index >= len(self.location_list): + return None + return index + def update_z(self): + index = self._selected_location_index() + if index is None: + self._log.error("Cannot update Z, because no location is selected") + return + z_mm = self.stage.get_pos().z_mm - index = self.dropdown_location_list.currentIndex() + region_id = self.location_ids[index] self.location_list[index, 2] = z_mm - self.scanCoordinates.region_centers[self.location_ids[index]][2] = z_mm - self.scanCoordinates.region_fov_coordinates[self.location_ids[index]] = [ - (coord[0], coord[1], z_mm) - for coord in self.scanCoordinates.region_fov_coordinates[self.location_ids[index]] - ] - location_str = f"x:{round(self.location_list[index,0],3)} mm y:{round(self.location_list[index,1],3)} mm z:{round(z_mm * 1000.0,3)} μm" - self.dropdown_location_list.setItemText(index, location_str) + + # Z does not change the grid footprint, so the FOVs only need their z rewritten + if region_id in self.scanCoordinates.region_centers: + self.scanCoordinates.region_centers[region_id][2] = z_mm + if region_id in self.scanCoordinates.region_fov_coordinates: + self.scanCoordinates.region_fov_coordinates[region_id] = [ + (coord[0], coord[1], z_mm) for coord in self.scanCoordinates.region_fov_coordinates[region_id] + ] + + self.dropdown_location_list.setItemText( + index, self._location_str(self.location_list[index, 0], self.location_list[index, 1], z_mm) + ) + # Keep the table in sync without re-entering cell_was_changed + self.table_location_list.blockSignals(True) + if self.table_location_list.rowCount() > index: + self.table_location_list.setItem(index, 2, QTableWidgetItem(str(round(z_mm * 1000, 1)))) + self.table_location_list.blockSignals(False) + + def update_xy(self): + index = self._selected_location_index() + if index is None: + self._log.error("Cannot update XY, because no location is selected") + return + + pos = self.stage.get_pos() + x = pos.x_mm + y = pos.y_mm + z = self.location_list[index, 2] # Z is left alone, that is what Set Z is for + region_id = self.location_ids[index] + + # Erase the old grid rectangles before the region is re-tiled at the new center + if region_id in self.scanCoordinates.region_fov_coordinates: + self.navigationViewer.deregister_fovs_from_image(self.scanCoordinates.region_fov_coordinates[region_id]) + + self.location_list[index, 0] = x + self.location_list[index, 1] = y + + # Re-tile the region at the new center; this re-registers its FOVs on the viewer + if self.use_overlap: + self.scanCoordinates.add_flexible_region( + region_id, + x, + y, + z, + self.entry_NX.value(), + self.entry_NY.value(), + overlap_percent=self.entry_overlap.value(), + ) + else: + self.scanCoordinates.add_flexible_region_with_step_size( + region_id, + x, + y, + z, + self.entry_NX.value(), + self.entry_NY.value(), + self.entry_deltaX.value(), + self.entry_deltaY.value(), + ) + + self.dropdown_location_list.setItemText(index, self._location_str(x, y, z)) + # Keep the table in sync without re-entering cell_was_changed + self.table_location_list.blockSignals(True) + if self.table_location_list.rowCount() > index: + self.table_location_list.setItem(index, 0, QTableWidgetItem(str(round(x, 3)))) + self.table_location_list.setItem(index, 1, QTableWidgetItem(str(round(y, 3)))) + self.table_location_list.blockSignals(False) + + self.refresh_position_labels() def update_Nz(self): z_min = self.entry_minZ.value() @@ -6746,6 +6837,8 @@ def update_fov_positions(self): self.entry_deltaY.value(), ) + self.refresh_position_labels() + def set_deltaZ(self, value): if self.checkbox_usePiezo.isChecked(): deltaZ = value @@ -6960,6 +7053,7 @@ def add_location(self): # Re-enable signals self.table_location_list.blockSignals(False) self.dropdown_location_list.blockSignals(False) + self.refresh_position_labels() print(f"Added Region: {region_id} - x={x}, y={y}, z={z}") else: print("Invalid Region: Duplicate Location") @@ -7020,6 +7114,7 @@ def remove_location(self): # Re-enable signals self.table_location_list.blockSignals(False) self.dropdown_location_list.blockSignals(False) + self.refresh_position_labels() def next(self): index = self.dropdown_location_list.currentIndex() @@ -7040,8 +7135,15 @@ def next(self): self.stage.move_z_to(z) def previous(self): + num_regions = self.dropdown_location_list.count() + if num_regions <= 0: + self._log.error("Cannot move to previous location, because there are no locations in the list") + return + index = self.dropdown_location_list.currentIndex() - index = max(index - 1, 0) + # With nothing selected, stepping back should land on the last entry, not on + # (-1 - 1) % num_regions, which would silently skip to the second to last. + index = num_regions - 1 if index < 0 else (index - 1) % num_regions self.dropdown_location_list.setCurrentIndex(index) x = self.location_list[index, 0] y = self.location_list[index, 1] @@ -7058,6 +7160,7 @@ def clear(self): self.table_location_list.setRowCount(0) self.navigationViewer.clear_overlay() + self.refresh_position_labels() self._log.info("Cleared all locations and overlays.") def clear_only_location_list(self): @@ -7065,6 +7168,7 @@ def clear_only_location_list(self): self.location_ids = np.empty((0,), dtype="