diff --git a/src/aare/gui/gui.py b/src/aare/gui/gui.py index 9ce12798..51995574 100644 --- a/src/aare/gui/gui.py +++ b/src/aare/gui/gui.py @@ -113,6 +113,9 @@ def main(): if pred_zmq_addr == "": pred_zmq_addr = None + if pred_zmq_addr is None: + pred_zmq_addr = zmq_addr + if parser.isSet(defaultImage): default_image = parser.value(defaultImage) else: diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index aa476da3..3aee036d 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -36,6 +36,7 @@ from aare.gui.panels.target_stability_panel import TargetStabilityPanel from aare.gui.panels.tell_sample_panel import TellSamplePanel from aare.gui.panels.face_detection_panel import FaceDetectionPanel from aare.gui.panels.smargon_trace_panel import SmargonTracePanel +from aare.gui.panels.axis_video_panel import AxisVideoPanel from aare.gui.scan_logic.raster_grid_manager import RasterGridManager from aare.gui.scan_logic.rotation_scan_manager import RotationScanManager from aare.gui.scan_logic.sample_mount_logic import SampleMountLogic @@ -50,7 +51,6 @@ from aare.gui.tutorials.tutroial_texts import MANUAL_MOUNT_TUTORIAL from aare.gui.tutorials.controls_help_dialog import ControlsHelpDialog -from aare.gui.threads.camera_thread import SampleCameraThread from aare.gui.threads.prediction_subscriber import PredictionSubscriber from aare.gui.threads.daq_worker import DAQWorker from aare.gui.threads.jfjoch_viewer import JFJochDBusClient @@ -94,6 +94,13 @@ class MainWindow(QMainWindow): self._cleanup_done = False self._default_window_state = None + self._beamline_cam_addr = beamline_cam_addr + self._gonio_cam_addr = gonio_cam_addr + self._gonio_cam_id = gonio_cam_id + self._axis_camera_refresh_interval_ms = 60 * 60 * 1000 + self.beamline_camera_thread = None + self.gonio_camera_thread = None + self._show_beamline_state_panel_for_users = True self._waiting_for_baton_response: bool = False @@ -222,6 +229,14 @@ class MainWindow(QMainWindow): self.sample_camera = SampleCameraImageLabel(geom=geom, raster=self.raster, parent=top_widget, default_image=default_image) + self.beamline_view = VideoGraphicsView() + self.beamline_view_panel = AxisVideoPanel("Beamline view", self.beamline_view, parent=top_widget) + self.beamline_view_panel.refresh_requested.connect(self.refresh_axis_cameras) + + self.gonio_view = VideoGraphicsView() + self.gonio_view_panel = AxisVideoPanel("Gonio camera", self.gonio_view, parent=top_widget) + self.gonio_view_panel.refresh_requested.connect(self.refresh_axis_cameras) + self.beamline_view_container = QWidget(parent=top_widget) self.beamline_view_layout = QVBoxLayout(self.beamline_view_container) self.beamline_view_layout.setContentsMargins(0, 0, 0, 0) @@ -232,29 +247,20 @@ class MainWindow(QMainWindow): self.beamline_view_layout.addWidget(self.beamline_view_1_combined) self.beamline_view_layout.addWidget(self.beamline_view_2_combined) - self.beamline_view = VideoGraphicsView() - if beamline_cam_addr: - self.beamline_camera_thread = VideoThread(ip=beamline_cam_addr) - self.beamline_camera_thread.frame_ready.connect(self.beamline_view.update_frame) - self.beamline_camera_thread.frame_ready.connect(self.beamline_view_2_combined.update_frame) - self.beamline_camera_thread.start() - self.beamline_view_layout.addWidget(self.beamline_view) - - self.gonio_view = VideoGraphicsView() - # TODO add option to change cameras for gonio_camera_thread - if gonio_cam_addr and gonio_cam_id: - self.gonio_camera_thread = VideoThread(ip=gonio_cam_addr, camera=gonio_cam_id) - self.gonio_camera_thread.frame_ready.connect(self.gonio_view.update_frame) - self.gonio_camera_thread.frame_ready.connect(self.beamline_view_1_combined.update_frame) - self.gonio_camera_thread.start() - self.beamline_view_layout.addWidget(self.gonio_view) + self.beamline_combined_panel = AxisVideoPanel( + "Beamline combined view", + self.beamline_view_container, + parent=top_widget, + ) + self.beamline_combined_panel.refresh_requested.connect(self.refresh_axis_cameras) self.video_tab.addTab(self.sample_camera, "Sample camera") - self.video_tab.addTab(self.gonio_view, "Gonio camera") - self.video_tab.addTab(self.beamline_view, "Beamline view") - self.video_tab.addTab(self.beamline_view_container, "Beamline combined view") + self.video_tab.addTab(self.gonio_view_panel, "Gonio camera") + self.video_tab.addTab(self.beamline_view_panel, "Beamline view") + self.video_tab.addTab(self.beamline_combined_panel, "Beamline combined view") top_widget_layout.addWidget(self.video_tab) + self._start_axis_camera_threads() beamline_controls_scroll = NoWheelScrollArea(top_widget) @@ -430,6 +436,11 @@ class MainWindow(QMainWindow): self._remote_close_timer.setInterval(1000) self._remote_close_timer.timeout.connect(self._check_remote_close_deadline) + self._axis_camera_refresh_timer = QTimer(self) + self._axis_camera_refresh_timer.setInterval(self._axis_camera_refresh_interval_ms) + self._axis_camera_refresh_timer.timeout.connect(self.refresh_axis_cameras) + self._axis_camera_refresh_timer.start() + self.daq.baton_status_changed.connect(self.status_bar.update_baton_status) self.daq.baton_status_changed.connect(self._on_baton_status_changed) self.daq.baton_request_result.connect(self._on_baton_request_result) @@ -488,45 +499,26 @@ class MainWindow(QMainWindow): self.beamline.samcam.target_color_changed.connect(self.sample_camera.set_target_color) self._restore_samcam_overlay_settings() - if zmq_addr is not None: - self.camera_thread = SampleCameraThread(zmq_url=zmq_addr) - 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") + sample_feed_addr = pred_zmq_addr or zmq_addr - # Prediction subscriber thread - if pred_zmq_addr is not None: - logger.debug(f"Starting prediction subscriber thread {pred_zmq_addr}") - self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic="detections") + if sample_feed_addr is not None: + logger.debug(f"Starting prediction subscriber thread {sample_feed_addr}") + self.prediction_thread = PredictionSubscriber(pred_zmq_url=sample_feed_addr, topic=b"") + self.prediction_thread.image.connect(self.sample_camera.update_pixmap) self.prediction_thread.prediction.connect(self.sample_camera.update_detections) self.prediction_thread.prediction.connect(self.prediction_metrics_panel.update_from_prediction) self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) self.prediction_thread.target_point.connect(self.target_stability_panel.update_target_point) + self.prediction_thread.focus_measure.connect(self.status_bar.update_sharpness) + self.prediction_thread.fps_measure.connect(self.status_bar.update_samcam_fps) + self.prediction_thread.camera_availability_changed.connect(self.sample_camera.set_camera_available) + self.prediction_thread.camera_availability_changed.connect(self._on_sample_camera_availability_changed) + self.prediction_thread.camera_error.connect(self._on_sample_camera_error) self.prediction_thread.start() else: self.prediction_thread = None - - self._last_pred_image_ts: float | None = None - self._pred_preferred_timeout_s: float = 0.7 # tune: how long we "trust" prediction images - self._pred_is_preferred: bool = False - - if self.camera_thread is not None: - self.camera_thread.camera_image.connect(self._on_samcam_camera_pixmap) - - if self.prediction_thread is not None: - self.prediction_thread.image.connect(self._on_samcam_prediction_pixmap) - - self._samcam_source_timer = QTimer(self) - self._samcam_source_timer.setInterval(200) # ms - self._samcam_source_timer.timeout.connect(self._update_samcam_source_preference) - self._samcam_source_timer.start() + self.sample_camera.set_camera_available(False) + self._show_samcam_feed_banner("Sample camera feed unavailable: no stream configured") # # self.data_collection.helical.helical_scan.connect(self.worker.helical_scan) @@ -621,8 +613,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) - if self.camera_thread is not None: - self.daq.update.connect(self.camera_thread.update_daq_status) + if self.prediction_thread is not None: + self.daq.update.connect(self.prediction_thread.update_daq_status) if self.__decoded_token.staff: self.daq.update.connect(self.beamline.monochromator_panel.update_daq_status) @@ -767,31 +759,6 @@ class MainWindow(QMainWindow): settings.setValue("samcam/compact_overlay_legend", overlay["compact_overlay_legend"]) settings.setValue("samcam/target_color", overlay["target_color"]) - @Slot(QPixmap) - def _on_samcam_prediction_pixmap(self, pix: QPixmap) -> None: - self._last_pred_image_ts = time.monotonic() - self._pred_is_preferred = True - self.sample_camera.update_pixmap(pix) - - @Slot(QPixmap) - def _on_samcam_camera_pixmap(self, pix: QPixmap) -> None: - # Only show camera frames when prediction is not currently "healthy" - if not self._pred_is_preferred: - self.sample_camera.update_pixmap(pix) - - @Slot() - def _update_samcam_source_preference(self) -> None: - if self.prediction_thread is None: - self._pred_is_preferred = False - return - - if self._last_pred_image_ts is None: - self._pred_is_preferred = False - return - - 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: @@ -816,6 +783,57 @@ class MainWindow(QMainWindow): self.alert_banner_secondary.clear_message() self.__samcam_feed_banner_active = False + def _stop_axis_camera_threads(self) -> None: + for attr_name in ("beamline_camera_thread", "gonio_camera_thread"): + thread = getattr(self, attr_name, None) + if thread is None: + continue + + try: + thread.stop() + except Exception as e: + logger.warning(f"Failed to stop {attr_name}: {e}") + + setattr(self, attr_name, None) + + @staticmethod + def _axis_busy_text_from_status(s: DAQStatusModel) -> str: + tell_state = getattr(s, "tell_state", None) + if tell_state is None: + return "BEAMLINE BUSY" + + activity_value = str(getattr(tell_state.activity, "value", "") or "").lower() + if activity_value not in {"mounting", "unmounting", "drying", "cooling"}: + return "BEAMLINE BUSY" + + try: + activity_name = tell_state.activity.display_name() + except Exception: + activity_name = activity_value.capitalize() if activity_value else "Busy" + + return f"TELL {activity_name}".upper() + + def _start_axis_camera_threads(self) -> None: + self._stop_axis_camera_threads() + + if self._beamline_cam_addr: + self.beamline_camera_thread = VideoThread(ip=self._beamline_cam_addr) + self.beamline_camera_thread.frame_ready.connect(self.beamline_view.update_frame) + self.beamline_camera_thread.frame_ready.connect(self.beamline_view_2_combined.update_frame) + self.beamline_camera_thread.start() + + if self._gonio_cam_addr and self._gonio_cam_id: + self.gonio_camera_thread = VideoThread(ip=self._gonio_cam_addr, camera=self._gonio_cam_id) + self.gonio_camera_thread.frame_ready.connect(self.gonio_view.update_frame) + self.gonio_camera_thread.frame_ready.connect(self.beamline_view_1_combined.update_frame) + self.gonio_camera_thread.start() + + @Slot() + def refresh_axis_cameras(self) -> None: + logger.info("Refreshing Axis camera threads") + self._stop_axis_camera_threads() + self._start_axis_camera_threads() + def start_text_tutorial(self) -> None: self.tutorial_manager.start("manual_workflow_demo") @@ -968,6 +986,12 @@ class MainWindow(QMainWindow): dev_help_action.triggered.connect(self.show_developer_help) help_menu.addAction(dev_help_action) + help_menu.addSeparator() + + refresh_axis_cameras_action = QAction("Refresh Axis Cameras", self) + refresh_axis_cameras_action.triggered.connect(self.refresh_axis_cameras) + help_menu.addAction(refresh_axis_cameras_action) + if self.__decoded_token.staff: local_contact_action = QAction("Local Contact", self) local_contact_action.triggered.connect(self.show_local_contact) @@ -1275,6 +1299,15 @@ class MainWindow(QMainWindow): if hasattr(self, "gonio_camera_thread") and self.gonio_camera_thread is not None: self.gonio_camera_thread.set_busy(s.busy) + axis_busy_text = self._axis_busy_text_from_status(s) + + if hasattr(self, "beamline_view_panel") and self.beamline_view_panel is not None: + self.beamline_view_panel.set_busy_state(bool(s.busy), axis_busy_text) + if hasattr(self, "gonio_view_panel") and self.gonio_view_panel is not None: + self.gonio_view_panel.set_busy_state(bool(s.busy), axis_busy_text) + if hasattr(self, "beamline_combined_panel") and self.beamline_combined_panel is not None: + self.beamline_combined_panel.set_busy_state(bool(s.busy), axis_busy_text) + self.target_stability_panel.set_beam_center( s.geom.beam_location_pxl.x, s.geom.beam_location_pxl.y, @@ -1578,17 +1611,22 @@ class MainWindow(QMainWindow): except Exception as e: logger.warning(f"Failed to stop _remote_close_timer: {e}") + try: + if hasattr(self, "_axis_camera_refresh_timer") and self._axis_camera_refresh_timer is not None: + self._axis_camera_refresh_timer.stop() + except Exception as e: + logger.warning(f"Failed to stop _axis_camera_refresh_timer: {e}") + try: if hasattr(self, "daq") and self.daq is not None: self.daq.cleanup() except Exception as e: logger.warning(f"Failed to clean up DAQ worker: {e}") + self._stop_axis_camera_threads() + for attr_name in ( - "camera_thread", "prediction_thread", - "beamline_camera_thread", - "gonio_camera_thread", ): thread = getattr(self, attr_name, None) if thread is None: diff --git a/src/aare/gui/panels/axis_video_panel.py b/src/aare/gui/panels/axis_video_panel.py new file mode 100644 index 00000000..a7999fbb --- /dev/null +++ b/src/aare/gui/panels/axis_video_panel.py @@ -0,0 +1,75 @@ +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton + +from aare.gui.widgets.video_image import VideoGraphicsView + + +class AxisVideoPanel(QWidget): + refresh_requested = Signal() + + def __init__(self, title: str, video_view: VideoGraphicsView | None = None, parent=None): + super().__init__(parent) + + self._title_label = QLabel(title, self) + self._title_label.setStyleSheet("font-weight: bold;") + + self._status_label = QLabel("", self) + self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_label.setMinimumWidth(140) + self._status_label.setStyleSheet( + "QLabel {" + " padding: 4px 10px;" + " border-radius: 10px;" + " background-color: #d9e2f2;" + " color: #2f3b52;" + "}" + ) + + self._refresh_button = QPushButton("Refresh Axis Cameras", self) + self._refresh_button.clicked.connect(self.refresh_requested.emit) + + self.view = video_view if video_view is not None else VideoGraphicsView() + + controls_layout = QHBoxLayout() + controls_layout.setContentsMargins(0, 0, 0, 0) + controls_layout.addWidget(self._title_label) + controls_layout.addStretch() + controls_layout.addWidget(self._status_label) + controls_layout.addWidget(self._refresh_button) + + root_layout = QVBoxLayout(self) + root_layout.setContentsMargins(0, 0, 0, 0) + root_layout.setSpacing(6) + root_layout.addLayout(controls_layout) + root_layout.addWidget(self.view) + + def set_busy_state(self, busy: bool, text: str | None = None) -> None: + status_text = (text or "BUSY").upper() if busy else "" + + if busy: + self._status_label.setText(status_text) + self._status_label.setStyleSheet( + "QLabel {" + " padding: 5px 12px;" + " border-radius: 11px;" + " background-color: #f7c948;" + " color: #3b2f00;" + " font-weight: bold;" + "}" + ) + else: + self._status_label.setText("") + self._status_label.setStyleSheet( + "QLabel {" + " padding: 4px 10px;" + " border-radius: 10px;" + " background-color: #d9e2f2;" + " color: #2f3b52;" + "}" + ) + + if hasattr(self.view, "set_busy_overlay"): + self.view.set_busy_overlay(busy, status_text or "BUSY") + + def set_status_text(self, text: str) -> None: + self._status_label.setText(text or "") \ No newline at end of file diff --git a/src/aare/gui/threads/axis_video_thread.py b/src/aare/gui/threads/axis_video_thread.py index 24accb63..f488df66 100644 --- a/src/aare/gui/threads/axis_video_thread.py +++ b/src/aare/gui/threads/axis_video_thread.py @@ -10,7 +10,7 @@ class VideoThread(QThread): frame_ready = Signal(QImage) error_occurred = Signal(str) - def __init__(self, ip: str, camera = 1): + def __init__(self, ip: str, camera=1): super().__init__() self.camera_ip = ip self.running = False @@ -53,7 +53,7 @@ class VideoThread(QThread): boundary = None for chunk in response.iter_content(chunk_size=1024): - if not self.running: + if not self.running or self.isInterruptionRequested(): break if chunk: @@ -80,8 +80,13 @@ class VideoThread(QThread): except Exception as e: self.error_occurred.emit(f"Unexpected error: {str(e)}") finally: + self.running = False if self.session: - self.session.close() + try: + self.session.close() + except Exception: + pass + self.session = None def _process_buffer(self, buffer, boundary): """Process buffer to extract JPEG frames""" @@ -122,16 +127,22 @@ class VideoThread(QThread): self.last_good_frame = qt_image self.frame_ready.emit(qt_image) - except Exception as e: + except Exception: # If frame processing fails, use last good frame if available if self.last_good_frame is not None: self.frame_ready.emit(self.last_good_frame) - def stop(self): self.running = False - if self.session: - self.session.close() - self.quit() - self.wait() + self.requestInterruption() + if self.session: + try: + self.session.close() + except Exception: + pass + finally: + self.session = None + + if self.isRunning(): + self.wait(5000) \ No newline at end of file diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 92667958..2a0e9f9f 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -1,14 +1,17 @@ import json +import time + import numpy as np import zmq -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import QThread, Signal, Slot from PySide6.QtGui import QImage, QPixmap -# If you need Bayer conversion like your SampleCameraThread did: import cv2 +from aare.common.autofocus_tools import focus_measure_edges from aare.common.logger_config import setup_logger +from aare.common.models import DAQStatusModel logger = setup_logger("aareGUI") @@ -16,6 +19,10 @@ class PredictionSubscriber(QThread): prediction = Signal(dict) target_point = Signal(dict) image = Signal(QPixmap) + focus_measure = Signal(float) + fps_measure = Signal(float) + camera_availability_changed = Signal(bool) + camera_error = Signal(str) def __init__(self, pred_zmq_url: str, topic: bytes | str = b"", parent=None): super().__init__(parent) @@ -25,6 +32,23 @@ class PredictionSubscriber(QThread): self._sock.setsockopt(zmq.LINGER, 0) self._emit_images = True + self.running = True + + self._measure_focus = False + self._focus_mask = None + self._beam_x = 0 + self._beam_y = 0 + self._last_beam_pos = None + self._focus_radius = 40 + + self._fps_window_start = time.perf_counter() + self._fps_frame_count = 0 + self._fps_emit_period_s = 0.5 + self._last_frame_time = None + self._no_frame_timeout_s = 5.0 + + self._camera_available = False + self._last_camera_error: str | None = None if isinstance(topic, str): self._sock.setsockopt_string(zmq.SUBSCRIBE, topic) @@ -34,11 +58,34 @@ class PredictionSubscriber(QThread): self._sock.setsockopt(zmq.SUBSCRIBE, b"") self._sock.connect(pred_zmq_url) - self.running = True def set_emit_images(self, enabled: bool) -> None: self._emit_images = enabled + 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 + self._beam_y = s.geom.beam_location_pxl.y + if (self._beam_x, self._beam_y) != self._last_beam_pos: + self._focus_mask = None + self._last_beam_pos = (self._beam_x, self._beam_y) + + @Slot(bool) + def enable_focus_measurement(self, enabled: bool = True): + self._measure_focus = enabled + def _try_parse_json(self, part: bytes) -> dict | None: try: decoded = json.loads(part.decode("utf-8")) @@ -46,49 +93,107 @@ class PredictionSubscriber(QThread): except Exception: return None - def _decode_image(self, header: dict, data: bytes) -> QPixmap | None: - """ - Supports: - - header["type"] == "uint8" - - header["shape"] == [H, W] (Bayer) -> converted to RGB - - header["shape"] == [H, W, 3] (RGB) -> used directly - """ - if not header or header.get("type") != "uint8": - return None - shape = header.get("shape") - if not shape or not isinstance(shape, (list, tuple)): + def _decode_rgb_image(self, header: dict, data: bytes) -> np.ndarray | None: + if not header: return None arr = np.frombuffer(data, dtype=np.uint8) - if len(shape) == 2: - h, w = int(shape[0]), int(shape[1]) - if arr.size != h * w: + encoding = str(header.get("encoding", "")).lower() + if encoding == "jpeg": + encoded = np.frombuffer(data, dtype=np.uint8) + bgr = cv2.imdecode(encoded, cv2.IMREAD_COLOR) + if bgr is None: return None - bayer = arr.reshape((h, w)) - rgb = cv2.cvtColor(bayer, cv2.COLOR_BAYER_GB2RGB) - elif len(shape) == 3 and int(shape[2]) == 3: - h, w, c = int(shape[0]), int(shape[1]), int(shape[2]) - if arr.size != h * w * c: - return None - rgb = arr.reshape((h, w, 3)) - else: - return None + return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) - qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy() + if header.get("type") == "uint8": + shape = header.get("shape") + if not shape or not isinstance(shape, (list, tuple)): + return None + + if len(shape) == 2: + h, w = int(shape[0]), int(shape[1]) + if arr.size != h * w: + return None + bayer = arr.reshape((h, w)) + return cv2.cvtColor(bayer, cv2.COLOR_BAYER_GB2RGB) + + if len(shape) == 3 and int(shape[2]) == 3: + h, w, c = int(shape[0]), int(shape[1]), int(shape[2]) + if arr.size != h * w * c: + return None + return arr.reshape((h, w, 3)) + + return None + + def _rgb_to_pixmap(self, rgb: np.ndarray) -> QPixmap: + qimage = QImage( + rgb.data, + rgb.shape[1], + rgb.shape[0], + QImage.Format.Format_RGB888, + ).copy() return QPixmap.fromImage(qimage) + def _emit_focus_measure_if_enabled(self, rgb: np.ndarray) -> None: + if not self._measure_focus: + return + + gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) + if self._focus_mask is None or self._focus_mask.shape != gray.shape: + height, width = gray.shape + y, x = np.ogrid[:height, :width] + self._focus_mask = ( + (x - self._beam_x) ** 2 + (y - self._beam_y) ** 2 <= self._focus_radius ** 2 + ) + + sharpness = focus_measure_edges(gray, self._focus_mask) + self.focus_measure.emit(sharpness) + def run(self): + self._debug_last_log_ts = time.perf_counter() + self._debug_msg_count = 0 try: while self.running: try: parts = self._sock.recv_multipart() + self._debug_msg_count += 1 + debug_now = time.perf_counter() + if debug_now - self._debug_last_log_ts >= 1.0: + logger.info(f"PredictionSubscriber received {self._debug_msg_count} messages/s") + self._debug_msg_count = 0 + self._debug_last_log_ts = debug_now except zmq.Again: + now = time.perf_counter() + elapsed = now - self._fps_window_start + if elapsed >= self._fps_emit_period_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 if not parts: continue + now = time.perf_counter() + self._last_frame_time = now + self._fps_frame_count += 1 + + elapsed = now - self._fps_window_start + if elapsed >= self._fps_emit_period_s: + fps = self._fps_frame_count / elapsed if elapsed > 0 else 0.0 + self.fps_measure.emit(float(fps)) + self._fps_window_start = now + self._fps_frame_count = 0 + json_dicts: list[dict] = [] non_json_parts: list[bytes] = [] @@ -100,18 +205,28 @@ class PredictionSubscriber(QThread): non_json_parts.append(p) header = next( - (d for d in json_dicts if "shape" in d and d.get("type") == "uint8"), + ( + d for d in json_dicts + if d.get("encoding") == "jpeg" + or ("shape" in d and d.get("type") == "uint8") + ), None, ) detections = next((d for d in json_dicts if "boxes" in d), None) target = next((d for d in json_dicts if "target_point" in d), None) - image_bytes = max(non_json_parts, key=len) if non_json_parts else None if self._emit_images and header and image_bytes: - pix = self._decode_image(header, image_bytes) - if pix is not None and self.running: - self.image.emit(pix) + rgb = self._decode_rgb_image(header, image_bytes) + if rgb is None: + self._set_camera_available(False, "Sample camera feed unavailable: failed to decode frame") + else: + self._set_camera_available(True) + self._emit_focus_measure_if_enabled(rgb) + if self.running: + self.image.emit(self._rgb_to_pixmap(rgb)) + elif self._emit_images: + self._set_camera_available(False, "Sample camera feed unavailable: no frame header in zmq stream") if detections and self.running: self.prediction.emit(detections) @@ -121,6 +236,7 @@ class PredictionSubscriber(QThread): except Exception as e: if self.running: + self._set_camera_available(False, f"Sample camera feed unavailable: {e}") logger.exception(f"PredictionSubscriber error: {e}") finally: try: diff --git a/src/aare/gui/widgets/video_image.py b/src/aare/gui/widgets/video_image.py index a3103737..6924266e 100644 --- a/src/aare/gui/widgets/video_image.py +++ b/src/aare/gui/widgets/video_image.py @@ -1,5 +1,5 @@ -from PySide6.QtCore import Qt, Slot -from PySide6.QtGui import QPainter, QPixmap, QImage +from PySide6.QtCore import Qt, Slot, QRectF +from PySide6.QtGui import QPainter, QPixmap, QImage, QFont, QColor, QPen, QFontMetrics from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem @@ -30,6 +30,9 @@ class VideoGraphicsView(QGraphicsView): self.min_zoom = 0.1 self.max_zoom = 5.0 + self._busy_overlay_visible = False + self._busy_overlay_text = "BUSY" + @Slot(QImage) def update_frame(self, qt_image : QImage): """Update the video frame""" @@ -40,6 +43,13 @@ class VideoGraphicsView(QGraphicsView): if self.zoom_factor == 1.0: self.fit_to_view() + self.viewport().update() + + def set_busy_overlay(self, visible: bool, text: str = "BUSY") -> None: + self._busy_overlay_visible = bool(visible) + self._busy_overlay_text = str(text or "BUSY").upper() + self.viewport().update() + def fit_to_view(self): """Fit the video to the view""" if self.pixmap_item.pixmap().isNull(): @@ -92,3 +102,48 @@ class VideoGraphicsView(QGraphicsView): self.zoom_out() else: super().keyPressEvent(event) + + def drawForeground(self, painter: QPainter, rect: QRectF): + super().drawForeground(painter, rect) + + if not self._busy_overlay_visible: + return + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + font.setPointSize(24) + font.setBold(True) + painter.setFont(font) + + text = self._busy_overlay_text + fm = QFontMetrics(font) + text_rect = fm.boundingRect(text) + + padding_x = 20 + padding_y = 14 + bg_width = text_rect.width() + padding_x * 2 + bg_height = text_rect.height() + padding_y * 2 + + viewport_width = self.viewport().width() + viewport_height = self.viewport().height() + + pos_x = int((viewport_width - bg_width) / 2) + pos_y = int(viewport_height * 0.68 - bg_height / 2) + + bg_rect = QRectF(pos_x, pos_y, bg_width, bg_height) + + painter.setPen(QPen(QColor(255, 255, 255, 220), 2)) + painter.setBrush(QColor(200, 30, 30, 185)) + painter.drawRoundedRect(bg_rect, 14, 14) + + painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.drawText( + bg_rect.left() + padding_x, + bg_rect.top() + padding_y + fm.ascent(), + text, + ) + + painter.restore() \ No newline at end of file