From 63d0acc227352579fae282d04754520eb0f17b1e Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 10 Jul 2025 14:03:13 +0200 Subject: [PATCH] GUI: Add beamline camera view --- gui/src/aaregui/main_window.py | 66 ++++++++++---- gui/src/aaregui/threads/axis_video_thread.py | 81 +++++++++++++++++ gui/src/aaregui/widgets/video_image.py | 94 ++++++++++++++++++++ 3 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 gui/src/aaregui/threads/axis_video_thread.py create mode 100644 gui/src/aaregui/widgets/video_image.py diff --git a/gui/src/aaregui/main_window.py b/gui/src/aaregui/main_window.py index 7ba9f925..b08273b2 100644 --- a/gui/src/aaregui/main_window.py +++ b/gui/src/aaregui/main_window.py @@ -9,11 +9,11 @@ from PySide6.QtWidgets import ( QVBoxLayout, QMessageBox, QApplication, - QDockWidget) + QDockWidget, QTabWidget) from aaredaqlib.coordinate import Coordinate, SmargonCoordinate from aaredaqlib.diffraction_geometry import DiffractionGeometry -from aaredaqlib.models import SampleShortInfoList, TokenData +from aaredaqlib.models import SampleShortInfoList, TokenData, DAQStatusModel, BeamlineStateEnum from aaredaqlib.sample_geometry import SampleGeometryModel from aaregui.panels.beamline_controls import BeamlineControls @@ -24,12 +24,14 @@ from aaregui.panels.tell_sample_panel import TellSamplePanel from aaregui.scan_logic.raster_grid_manager import RasterGridManager from aaregui.scan_logic.rotation_scan_manager import RotationScanManager from aaregui.scan_logic.sample_mount_logic import SampleMountLogic +from aaregui.threads.axis_video_thread import VideoThread from aaregui.threads.camera_thread import SampleCameraThread from aaregui.threads.daq_worker import DAQWorker from aaregui.threads.jfjoch_viewer import JFJochDBusClient from aaregui.widgets.camera_image import SampleCameraImageLabel from aaregui.widgets.no_wheel_scroll_area import NoWheelScrollArea from aaregui.widgets.status_bar import StatusBar +from aaregui.widgets.video_image import VideoGraphicsView class MainWindow(QMainWindow): @@ -42,6 +44,7 @@ class MainWindow(QMainWindow): super().__init__() self.__base_url = base_url self.__token = token + self.__mounting = False self.viewer = JFJochDBusClient() @@ -93,9 +96,19 @@ class MainWindow(QMainWindow): collection_controls_scroll.setFixedWidth(self.data_collection.set_width + 10) - self.camera_image = SampleCameraImageLabel(geom=geom, raster=self.raster, parent=top_widget, default_image=default_image) + self.video_tab = QTabWidget(parent=top_widget) - top_widget_layout.addWidget(self.camera_image) + self.sample_camera = SampleCameraImageLabel(geom=geom, raster=self.raster, parent=top_widget, default_image=default_image) + + self.beamline_view = VideoGraphicsView() + self.beamline_camera_thread = VideoThread(ip="x06da-axis-1.psi.ch") + self.beamline_camera_thread.frame_ready.connect(self.beamline_view.update_frame) + self.beamline_camera_thread.start() + + self.video_tab.addTab(self.sample_camera, "Sample camera") + self.video_tab.addTab(self.beamline_view, "Beamline view") + + top_widget_layout.addWidget(self.video_tab) beamline_controls_scroll = NoWheelScrollArea(top_widget) @@ -146,15 +159,15 @@ class MainWindow(QMainWindow): self.beamline.loopctr.bounding_box.clicked.connect(self.daq.ml_bounding_box) self.daq.raster_generated_by_ml.connect(self.raster.update_active_grid_request) - self.camera_image.smargon.connect(self.daq.move_smargon) + self.sample_camera.smargon.connect(self.daq.move_smargon) self.beamline.smargon_panel.smargon.connect(self.daq.move_smargon) - self.camera_image.samcam_updated.connect(self.daq.samcam_settings) + self.sample_camera.samcam_updated.connect(self.daq.samcam_settings) self.raster.omega.connect(self.daq.set_omega) self.raster.smargon.connect(self.daq.move_smargon) self.beamline.omega_panel.set_omega.connect(self.daq.set_omega) - self.camera_image.set_omega.connect(self.daq.set_omega) + self.sample_camera.set_omega.connect(self.daq.set_omega) self.beamline.zoom_panel.zoom.connect(self.daq.zoom) self.beamline.illumination_panel.light.connect(self.daq.light) self.beamline.abr_tweak.abr_tweak.connect(self.daq.abr_tweak) @@ -162,15 +175,15 @@ class MainWindow(QMainWindow): self.beamline.abr_tweak.abr_goto_meas.connect(self.daq.abr_goto_meas) self.beamline.beam_mark.beam_mark_clear.connect(self.daq.beam_mark_clear) - self.camera_image.update_beam_mark.connect(self.daq.beam_mark_add) + self.sample_camera.update_beam_mark.connect(self.daq.beam_mark_add) self.beamline.beam_center.beam_center.connect(self.daq.beam_center) self.beamline.beam_size.beam_size.connect(self.daq.beam_size_mm) - self.camera_image.load_image.connect(self.raster.load_image) - self.camera_image.switch_raster_grid.connect(self.data_collection.switch_to_raster) + self.sample_camera.load_image.connect(self.raster.load_image) + self.sample_camera.switch_raster_grid.connect(self.data_collection.switch_to_raster) if zmq_addr is not None: self.camera_thread = SampleCameraThread(zmq_url=zmq_addr) - self.camera_thread.camera_image.connect(self.camera_image.update_pixmap) + self.camera_thread.camera_image.connect(self.sample_camera.update_pixmap) self.camera_thread.start() else: self.camera_thread = None @@ -184,13 +197,13 @@ class MainWindow(QMainWindow): # ) # - self.camera_image.autofocus.connect(self.daq.autofocus) + self.sample_camera.autofocus.connect(self.daq.autofocus) - self.camera_image.evaluate_grid.connect(self.raster.run_grid_scan) + self.sample_camera.evaluate_grid.connect(self.raster.run_grid_scan) self.data_collection.raster.evaluate_grid.connect(self.raster.run_grid_scan) - self.camera_image.clear_evaluated_grids.connect(self.raster.clear_completed_grids) - self.camera_image.clear_grid.connect(self.raster.clear_active_grid) + self.sample_camera.clear_evaluated_grids.connect(self.raster.clear_completed_grids) + self.sample_camera.clear_grid.connect(self.raster.clear_active_grid) self.daq.run_number_incremented.connect(self.data_collection.file_path_panel.increment_run_number) self.job_list_panel.auto_scan.connect(self.daq.automated_scan) @@ -205,7 +218,7 @@ class MainWindow(QMainWindow): self.data_collection.raster.transmission_updated.connect(self.raster.update_transmission) self.data_collection.raster.dtz_updated.connect(self.raster.update_dtz) self.data_collection.raster.grid_metric_updated.connect(self.raster.metric) - self.data_collection.raster.raster_alpha_changed.connect(self.camera_image.raster_alpha) + self.data_collection.raster.raster_alpha_changed.connect(self.sample_camera.raster_alpha) self.data_collection.cancel.connect(self.daq.cancel) self.raster.grid_scan.connect(self.daq.raster_scan) @@ -235,7 +248,7 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.data_collection.update_daq_status) self.daq.update.connect(self.beamline.illumination_panel.update_daq_status) self.daq.update.connect(self.raster.update_daq_status) - self.daq.update.connect(self.camera_image.update_daq_status) + 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.beamline.abr_tweak.update_daq_status) self.daq.update.connect(self.status_bar.update_daq_status) @@ -300,6 +313,23 @@ class MainWindow(QMainWindow): "Aare Macromolecular Crystallography GUI\nVersion: 1.0\nCopyright: Paul Scherrer Instiute 2024-2025", ) + @Slot() + def mount_view(self): + self.video_tab.setCurrentIndex(1) + + @Slot() + def sample_view(self): + self.video_tab.setCurrentIndex(0) + + @Slot(DAQStatusModel) + def update_daq_status(self, s: DAQStatusModel): + if not self.__mounting and s.state == BeamlineStateEnum.RobotSampleExchange: + self.__mounting = True + self.video_tab.setCurrentIndex(1) + elif self.__mounting and s.state != BeamlineStateEnum.RobotSampleExchange: + self.__mounting = False + self.video_tab.setCurrentIndex(0) + @Slot(str) def display_error(self, msg: str): self.status_bar.setStyleSheet("color: red;") @@ -308,3 +338,5 @@ class MainWindow(QMainWindow): def cleanup(self): if self.camera_thread is not None: self.camera_thread.stop() + if self.beamline_camera_thread is not None: + self.beamline_camera_thread.stop() diff --git a/gui/src/aaregui/threads/axis_video_thread.py b/gui/src/aaregui/threads/axis_video_thread.py new file mode 100644 index 00000000..dde696da --- /dev/null +++ b/gui/src/aaregui/threads/axis_video_thread.py @@ -0,0 +1,81 @@ +import cv2 +from PySide6.QtCore import QThread, Signal +from PySide6.QtGui import QImage + + +class VideoThread(QThread): + frame_ready = Signal(QImage) + error_occurred = Signal(str) + + def __init__(self, ip: str): + super().__init__() + self.camera_ip = ip + self.running = False + self.cap = None + self.last_good_frame = None + + def set_camera_ip(self, ip: str): + cv2.setLogLevel(cv2.LOG_LEVEL_ERROR) + self.camera_ip = ip + + def run(self): + # RTSP URL without credentials + rtsp_url = f"rtsp://{self.camera_ip}:554/axis-media/media.amp?videocodec=h264&resolution=1280x720" + + self.cap = cv2.VideoCapture(rtsp_url) + + if not self.cap.isOpened(): + self.error_occurred.emit(f"Could not connect to camera at {self.camera_ip}") + return + + # Set buffer size to reduce latency + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) + self.cap.set(cv2.CAP_PROP_FPS, 10) # Request 10 FPSs + + self.running = True + + while self.running: + ret, frame = self.cap.read() + + if ret and frame is not None: + # Check if frame is valid (not corrupted) + if frame.shape[0] > 0 and frame.shape[1] > 0: + try: + # Convert BGR to RGB + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Convert to QImage + height, width, channel = rgb_frame.shape + bytes_per_line = 3 * width + qt_image = QImage(rgb_frame.data, width, height, bytes_per_line, QImage.Format.Format_RGB888) + + # Store as last good frame + self.last_good_frame = qt_image + self.frame_ready.emit(qt_image) + + except Exception as e: + # 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) + # Don't emit error for minor decoding issues, just continue + pass + else: + # Invalid frame dimensions, use last good frame if available + if self.last_good_frame is not None: + self.frame_ready.emit(self.last_good_frame) + else: + # Frame read failed, use last good frame if available + if self.last_good_frame is not None: + self.frame_ready.emit(self.last_good_frame) + + # Small delay to prevent overwhelming the GUI + self.msleep(100) # ~10 FPS + + def stop(self): + self.running = False + print("Video thread stop requested") + if self.cap: + self.cap.release() + self.quit() + print("Video thread stopped. Exiting...") + self.wait() diff --git a/gui/src/aaregui/widgets/video_image.py b/gui/src/aaregui/widgets/video_image.py new file mode 100644 index 00000000..a3103737 --- /dev/null +++ b/gui/src/aaregui/widgets/video_image.py @@ -0,0 +1,94 @@ +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QPainter, QPixmap, QImage +from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem + + +class VideoGraphicsView(QGraphicsView): + def __init__(self): + super().__init__() + + # Create scene + self.scene = QGraphicsScene() + self.setScene(self.scene) + + # Create pixmap item for video frames + self.pixmap_item = QGraphicsPixmapItem() + self.scene.addItem(self.pixmap_item) + + # Configure view + self.setRenderHint(self.renderHints() | QPainter.RenderHint.Antialiasing) + self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter) + self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse) + + # Enable zooming and panning + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + # Initial settings + self.zoom_factor = 1.0 + self.min_zoom = 0.1 + self.max_zoom = 5.0 + + @Slot(QImage) + def update_frame(self, qt_image : QImage): + """Update the video frame""" + pixmap = QPixmap.fromImage(qt_image) + self.pixmap_item.setPixmap(pixmap) + + # Fit to view on first frame + if self.zoom_factor == 1.0: + self.fit_to_view() + + def fit_to_view(self): + """Fit the video to the view""" + if self.pixmap_item.pixmap().isNull(): + return + + self.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + self.zoom_factor = 1.0 + + def reset_zoom(self): + """Reset zoom to 100%""" + self.resetTransform() + self.zoom_factor = 1.0 + + def zoom_in(self): + """Zoom in""" + if self.zoom_factor < self.max_zoom: + factor = 1.25 + self.scale(factor, factor) + self.zoom_factor *= factor + + def zoom_out(self): + """Zoom out""" + if self.zoom_factor > self.min_zoom: + factor = 0.8 + self.scale(factor, factor) + self.zoom_factor *= factor + + def wheelEvent(self, event): + """Handle mouse wheel for zooming""" + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + # Zoom with Ctrl + wheel + if event.angleDelta().y() > 0: + self.zoom_in() + else: + self.zoom_out() + event.accept() + else: + # Normal scrolling + super().wheelEvent(event) + + def keyPressEvent(self, event): + """Handle keyboard shortcuts""" + if event.key() == Qt.Key.Key_F: + self.fit_to_view() + elif event.key() == Qt.Key.Key_R: + self.reset_zoom() + elif event.key() == Qt.Key.Key_Plus or event.key() == Qt.Key.Key_Equal: + self.zoom_in() + elif event.key() == Qt.Key.Key_Minus: + self.zoom_out() + else: + super().keyPressEvent(event)