From a0aff7967556b88913c381dc129df9e5c7f33dd3 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 20 Apr 2026 15:21:43 +0200 Subject: [PATCH] GUI: added protection against smargon and raster moves while camera is off --- src/aare/gui/main_window.py | 34 +++++++++- src/aare/gui/threads/camera_thread.py | 36 ++++++++-- src/aare/gui/widgets/camera_image.py | 97 ++++++++++++++++++++++++++- 3 files changed, 158 insertions(+), 9 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 84eddfbd..da1d360b 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -82,6 +82,8 @@ class MainWindow(QMainWindow): self.__base_url = base_url self.__token = token self.__mounting = False + self.__samcam_feed_banner_active = False + self.__samcam_feed_banner_message = "Sample camera feed unavailable" self._dev_help_dialog = None self._beamline_recovery_dialog = None self._controls_help_dialog = None @@ -388,8 +390,13 @@ class MainWindow(QMainWindow): self.camera_thread.start() self.camera_thread.focus_measure.connect(self.status_bar.update_sharpness) self.camera_thread.fps_measure.connect(self.status_bar.update_samcam_fps) + self.camera_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available) + self.camera_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed) + self.camera_thread.camera_error.connect(self._on_sample_camera_error) else: self.camera_thread = None + self.sample_camera.set_camera_available(False) + self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # Prediction subscriber thread if pred_zmq_addr is not None: @@ -499,7 +506,8 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.sample_camera.update_daq_status) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) - self.daq.update.connect(self.camera_thread.update_daq_status) + if self.camera_thread is not None: + self.daq.update.connect(self.camera_thread.update_daq_status) if self.__decoded_token.staff: self.daq.update.connect(self.beamline.beam_size.update_daq_status) @@ -612,6 +620,30 @@ class MainWindow(QMainWindow): age_s = time.monotonic() - self._last_pred_image_ts self._pred_is_preferred = age_s <= self._pred_preferred_timeout_s + @Slot(bool) + def _on_sample_camera_availability_changed(self, available: bool) -> None: + if available: + self._clear_samcam_feed_banner() + return + + self._show_samcam_feed_banner("Sample camera feed unavailable") + + @Slot(str) + def _on_sample_camera_error(self, message: str) -> None: + logger.warning(message) + self._show_samcam_feed_banner(message or "Sample camera feed unavailable") + + def _show_samcam_feed_banner(self, message: str) -> None: + self.__samcam_feed_banner_message = message + self.alert_banner_secondary.show_message(message, True) + self.__samcam_feed_banner_active = True + + def _clear_samcam_feed_banner(self) -> None: + if not self.__samcam_feed_banner_active: + return + self.alert_banner_secondary.clear_message() + self.__samcam_feed_banner_active = False + def start_text_tutorial(self) -> None: self.tutorial_manager.start("manual_workflow_demo") diff --git a/src/aare/gui/threads/camera_thread.py b/src/aare/gui/threads/camera_thread.py index 33320f58..d11744a9 100644 --- a/src/aare/gui/threads/camera_thread.py +++ b/src/aare/gui/threads/camera_thread.py @@ -15,6 +15,8 @@ class SampleCameraThread(QThread): camera_image = Signal(QPixmap) focus_measure = Signal(float) # Emits Laplacian variance (higher = sharper) fps_measure = Signal(float) + camera_availability_changed = Signal(bool) + camera_error = Signal(str) def __init__(self, zmq_url: str, parent=None): super().__init__(parent) @@ -38,6 +40,21 @@ class SampleCameraThread(QThread): self.__last_frame_time = None self.__no_frame_timeout_s = 5.0 + self.__camera_available = False + self.__last_camera_error: str | None = None + + def __set_camera_available(self, available: bool, error: str | None = None) -> None: + if available != self.__camera_available: + self.__camera_available = available + self.camera_availability_changed.emit(available) + + if error is not None and error != self.__last_camera_error: + self.__last_camera_error = error + self.camera_error.emit(error) + + if available: + self.__last_camera_error = None + @Slot(DAQStatusModel) def update_daq_status(self, s: DAQStatusModel): self.__beam_x = s.geom.beam_location_pxl.x @@ -89,7 +106,7 @@ class SampleCameraThread(QThread): encoded = np.frombuffer(data, dtype=np.uint8) bgr = cv2.imdecode(encoded, cv2.IMREAD_COLOR) if bgr is None: - print("Failed to decode JPEG image from zmq stream") + self.__set_camera_available(False, "Sample camera feed unavailable: failed to decode JPEG frame") continue rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) elif "shape" in header: @@ -97,9 +114,11 @@ class SampleCameraThread(QThread): raw = np.frombuffer(data, np.uint8).reshape((h, w)) rgb = cv2.cvtColor(raw, cv2.COLOR_BAYER_GB2RGB) else: - print(f"Unsupported header in zmq stream: {header}") + self.__set_camera_available(False, f"Sample camera feed unavailable: unsupported frame header {header}") continue + self.__set_camera_available(True) + if self.__measure_focus: gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) if self.__focus_mask is None or self.__focus_mask.shape != gray.shape: @@ -114,19 +133,24 @@ class SampleCameraThread(QThread): qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy() self.camera_image.emit(QPixmap.fromImage(qimage)) else: - print("NO HEADER in zmq stream") + self.__set_camera_available(False, "Sample camera feed unavailable: no frame header in zmq stream") except zmq.Again: # Timeout occurred now = time.perf_counter() elapsed = now - self.__fps_window_start if elapsed >= self.__fps_emit_period_s: - last = self.__fps_frame_count - no_frames_long = (last is None) or ((now - last) >= self.__no_frame_timeout_s) + no_frames_long = ( + self.__last_frame_time is None + or (now - self.__last_frame_time) >= self.__no_frame_timeout_s + ) self.fps_measure.emit(float("nan") if no_frames_long else 0.0) self.__fps_window_start = now self.__fps_frame_count = 0 + + if no_frames_long: + self.__set_camera_available(False, "Sample camera feed unavailable") continue # Check self.running again except Exception as e: - print(f"Error in sample camera thread {e}") + self.__set_camera_available(False, f"Sample camera feed unavailable: {e}") def stop(self): # Signal the thread to stop diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 2d87c82b..501d80b3 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -72,6 +72,7 @@ class SampleCameraImageLabel(QGraphicsView): self.__session_state: SessionsStateEnum | None = None self.__sam_cam = SampleCameraSettings(exposure=0.1, gain=100.0) self.__is_daq_busy = False + self.__camera_available = True self.__geom = geom self.__bookmarks: SmargonBookmarkList = SmargonBookmarkList() @@ -91,6 +92,7 @@ class SampleCameraImageLabel(QGraphicsView): self.setScene(self.scene) self.setMouseTracking(True) + self.__update_camera_interaction_feedback() if default_image is not None: pixmap = QPixmap(default_image) @@ -125,6 +127,30 @@ class SampleCameraImageLabel(QGraphicsView): self.__detections = [] # list of dicts from publisher self.__det_shape = None # shape from payload [h,w] so we can scale + def __update_camera_interaction_feedback(self) -> None: + if self.__camera_available: + self.viewport().setCursor(Qt.CursorShape.ArrowCursor) + self.setToolTip("") + else: + self.viewport().setCursor(Qt.CursorShape.ForbiddenCursor) + self.setToolTip("Sample camera feed unavailable") + + def __show_camera_unavailable_tooltip(self, event, action: str = "Sample camera interaction") -> None: + QToolTip.showText( + self.mapToGlobal(event.pos()), + f"{action} disabled: sample camera feed unavailable", + self, + ) + + def __camera_interaction_enabled(self) -> bool: + return self.__camera_available + + @Slot(bool) + def set_camera_available(self, available: bool): + self.__camera_available = available + self.__update_camera_interaction_feedback() + self.update() + @Slot(dict) def update_detections(self, payload: dict): # payload: { 'time', 'frame_id', 'shape':[h,w], 'boxes':[{'x1',...,'label','conf'}] } @@ -227,6 +253,42 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() + def __draw_camera_unavailable_overlay(self, painter: QPainter): + if self.__camera_available: + return + + painter.save() + painter.resetTransform() + + font = QFont() + font.setPointSize(22) + font.setBold(True) + painter.setFont(font) + + text = "Sample camera feed unavailable" + fm = QFontMetrics(font) + text_rect = fm.boundingRect(text) + padding = 16 + + position_x = 50 + position_y = 120 + + bg_rect = QRect( + position_x - padding, + position_y - padding, + text_rect.width() + 2 * padding, + text_rect.height() + 2 * padding, + ) + + painter.setPen(QPen(QColor(255, 255, 255, 220), 2)) + painter.setBrush(QColor(180, 60, 0, 180)) + painter.drawRoundedRect(bg_rect, 10, 10) + + painter.setPen(QPen(QColor(255, 255, 255))) + painter.drawText(QPoint(position_x, position_y + fm.ascent()), text) + + painter.restore() + def drawForeground(self, painter, rect): self.__draw_ml_bounding_box(painter) self.__draw_beam_center(painter) @@ -234,6 +296,7 @@ class SampleCameraImageLabel(QGraphicsView): self.__draw_helical(painter) self.__draw_busy_overlay(painter) self.__draw_session_overlay(painter) + self.__draw_camera_unavailable_overlay(painter) self.__draw_detections(painter, rect) def resizeEvent(self, event): @@ -241,6 +304,12 @@ class SampleCameraImageLabel(QGraphicsView): self.__scaling() def mousePressEvent(self, event): + if not self.__camera_interaction_enabled(): + if event.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.RightButton): + self.__show_camera_unavailable_tooltip(event) + event.accept() + return + self.start_point = self.mapToScene(event.pos()) match self.__state: @@ -272,6 +341,9 @@ class SampleCameraImageLabel(QGraphicsView): self.__raster_mgr.resize_active_grid(self.end_point) def mouseMoveEvent(self, event): + if not self.__camera_interaction_enabled(): + return + mouse_pos = self.mapToScene(event.pos()) if self.__state == SampleCameraImageState.IDLE: @@ -299,12 +371,24 @@ class SampleCameraImageLabel(QGraphicsView): self.update() def __on_raster_timer_timeout(self): + if self.__pending_load_pos is not None: + self.load_image.emit(self.__pending_load_pos) + self.__pending_load_pos = None + return + if self.__pending_load_pos is not None: self.load_image.emit(self.__pending_load_pos) self.__pending_load_pos = None self.raster_timer.start(self.raster_timer_interval) def mouseReleaseEvent(self, event): + if not self.__camera_interaction_enabled(): + self.click_timer.stop() + if self.__state != SampleCameraImageState.BEAM_MARKING: + self.__state = SampleCameraImageState.IDLE + event.accept() + return + if event.button() == Qt.MouseButton.LeftButton: if self.click_timer.isActive(): self.click_timer.stop() @@ -323,6 +407,9 @@ class SampleCameraImageLabel(QGraphicsView): self.__state = SampleCameraImageState.IDLE def __right_click_menu(self, event): + if not self.__camera_interaction_enabled(): + self.__show_camera_unavailable_tooltip(event) + return menu = QMenu(self) @@ -373,7 +460,6 @@ class SampleCameraImageLabel(QGraphicsView): elif action == grab_with_overlay_action: self.__screenshot_with_dialog(overlay=True) elif action == autofocus_action: - c = self.mapToScene(event.pos()) self.autofocus.emit(AutofocusSettings(center_x_pxl=None, center_y_pxl=None, radius_pxl=30, z_range_um=2000, @@ -471,6 +557,11 @@ class SampleCameraImageLabel(QGraphicsView): def __left_single_click(self, event): + if not self.__camera_interaction_enabled(): + logger.info("Ignoring click because sample camera feed is unavailable") + self.__show_camera_unavailable_tooltip(event, "Point-on-click") + return + match self.__state: case SampleCameraImageState.BEAM_MARKING: if event.button() == Qt.MouseButton.LeftButton and event.modifiers() & Qt.KeyboardModifier.ShiftModifier: @@ -521,6 +612,8 @@ class SampleCameraImageLabel(QGraphicsView): 'loop_all': QColor('green'), 'loop_face': QColor('yellow'), 'crystal': QColor('blue'), + 'needle': QColor('magenta'), + 'ice': QColor('cyan'), } for det in self.__detections: @@ -666,7 +759,7 @@ class SampleCameraImageLabel(QGraphicsView): mouse_scene_pos = self.mapToScene(mouse_view_pos) if event.key() == Qt.Key.Key_Shift: - if self.__state == SampleCameraImageState.IDLE: + if self.__state == SampleCameraImageState.IDLE and self.__camera_interaction_enabled(): if not self.raster_timer.isActive(): self.load_image.emit(mouse_scene_pos) self.raster_timer.start(self.raster_timer_interval)