From 5cbc0171f3698fa8b68733f6687a50844000170c Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 20 Apr 2026 16:14:18 +0200 Subject: [PATCH] GUI: visualisation of target position added to GUI --- src/aare/gui/main_window.py | 4 + src/aare/gui/panels/samcam_panel.py | 32 +++- src/aare/gui/threads/prediction_subscriber.py | 11 +- src/aare/gui/widgets/camera_image.py | 145 +++++++++++++++++- 4 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index da1d360b..dd40a9f3 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -384,6 +384,9 @@ class MainWindow(QMainWindow): self.sample_camera.load_image.connect(self.raster.load_image) self.sample_camera.switch_raster_grid.connect(self.data_collection.switch_to_raster) self.beamline.samcam.show_detections_changed.connect(self.sample_camera.set_show_detections) + self.beamline.samcam.show_target_point_changed.connect(self.sample_camera.set_show_target_point) + self.beamline.samcam.show_target_coordinates_changed.connect(self.sample_camera.set_show_target_coordinates) + self.beamline.samcam.target_color_changed.connect(self.sample_camera.set_target_color) if zmq_addr is not None: self.camera_thread = SampleCameraThread(zmq_url=zmq_addr) @@ -403,6 +406,7 @@ class MainWindow(QMainWindow): logger.debug(f"Starting prediction subscriber thread {pred_zmq_addr}") self.prediction_thread = PredictionSubscriber(pred_zmq_url=pred_zmq_addr, topic="detections") self.prediction_thread.prediction.connect(self.sample_camera.update_detections) + self.prediction_thread.target_point.connect(self.sample_camera.update_target_point) self.prediction_thread.start() else: self.prediction_thread = None diff --git a/src/aare/gui/panels/samcam_panel.py b/src/aare/gui/panels/samcam_panel.py index d08a1df1..22292a67 100644 --- a/src/aare/gui/panels/samcam_panel.py +++ b/src/aare/gui/panels/samcam_panel.py @@ -1,5 +1,5 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QCheckBox, QLineEdit, \ - QPushButton + QPushButton, QComboBox from PySide6.QtCore import Signal, Slot from aare.common.models import SampleCameraSettings, DAQStatusModel @@ -10,6 +10,9 @@ class SamcamPanel(QWidget): # Signals for when exposure or gain values change changed = Signal(SampleCameraSettings) show_detections_changed = Signal(bool) + show_target_point_changed = Signal(bool) + show_target_coordinates_changed = Signal(bool) + target_color_changed = Signal(str) screenshot_requested = Signal(str, str) old_settings = SampleCameraSettings(gain=100, exposure=0.001) @@ -72,6 +75,30 @@ class SamcamPanel(QWidget): self.show_detections_checkbox.toggled.connect(self.show_detections_changed.emit) detections_layout.addWidget(self.show_detections_checkbox) + # Show target point checkbox + target_point_layout = QHBoxLayout() + self.show_target_point_checkbox = QCheckBox("Show target point") + self.show_target_point_checkbox.setChecked(True) + self.show_target_point_checkbox.toggled.connect(self.show_target_point_changed.emit) + target_point_layout.addWidget(self.show_target_point_checkbox) + + # Show target coordinates checkbox + target_coords_layout = QHBoxLayout() + self.show_target_coordinates_checkbox = QCheckBox("Show target coordinates") + self.show_target_coordinates_checkbox.setChecked(True) + self.show_target_coordinates_checkbox.toggled.connect(self.show_target_coordinates_changed.emit) + target_coords_layout.addWidget(self.show_target_coordinates_checkbox) + + # Target color + target_color_layout = QHBoxLayout() + target_color_label = QLabel("Target colour:") + self.target_color_combo = QComboBox() + self.target_color_combo.addItems(["Cyan", "Dark Blue", "Dark Red"]) + self.target_color_combo.setCurrentText("Cyan") + self.target_color_combo.currentTextChanged.connect(self.target_color_changed.emit) + target_color_layout.addWidget(target_color_label) + target_color_layout.addWidget(self.target_color_combo) + # Add controls to main layout layout.addLayout(exposure_layout) layout.addLayout(gain_layout) @@ -79,6 +106,9 @@ class SamcamPanel(QWidget): layout.addLayout(screenshot_message_layout) layout.addWidget(self.screenshot_button) layout.addLayout(detections_layout) + layout.addLayout(target_point_layout) + layout.addLayout(target_coords_layout) + layout.addLayout(target_color_layout) self.setLayout(layout) def __changed(self): diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 2c1de0a7..acb1f45f 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -16,6 +16,9 @@ class PredictionSubscriber(QThread): # emits parsed JSON payload (dict with keys: time, frame_id, shape, boxes) prediction = Signal(dict) + # emit parsed target-point payload + target_point = Signal(dict) + # NEW: emit the image that came with the prediction stream image = Signal(QPixmap) @@ -25,14 +28,14 @@ class PredictionSubscriber(QThread): self._sock = self._ctx.socket(zmq.SUB) self._sock.setsockopt(zmq.RCVTIMEO, 500) self._sock.setsockopt(zmq.LINGER, 0) - + if isinstance(topic, str): self._sock.setsockopt_string(zmq.SUBSCRIBE, topic) elif isinstance(topic, bytes): self._sock.setsockopt(zmq.SUBSCRIBE, topic) else: self._sock.setsockopt(zmq.SUBSCRIBE, b"") - + self._sock.connect(pred_zmq_url) self.running = True @@ -101,6 +104,7 @@ class PredictionSubscriber(QThread): 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 @@ -112,6 +116,9 @@ class PredictionSubscriber(QThread): if detections and self.running: self.prediction.emit(detections) + if target and self.running: + self.target_point.emit(target) + except Exception as e: if self.running: logger.exception(f"PredictionSubscriber error: {e}") diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index 501d80b3..7795b9a8 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -1,7 +1,8 @@ import math +import time from enum import Enum -from PySide6.QtCore import Qt, QTimer, QRect, QPoint, Signal, Slot, QPointF +from PySide6.QtCore import Qt, QTimer, QRect, QPoint, Signal, Slot, QPointF, QRectF from PySide6.QtGui import ( QPixmap, QPainter, @@ -10,7 +11,7 @@ from PySide6.QtGui import ( QWheelEvent, QTransform, QCursor, - QLinearGradient, QFont, QFontMetrics, QPolygonF, + QLinearGradient, QFont, QFontMetrics, QPolygonF, QBrush, ) from PySide6.QtWidgets import ( QMenu, @@ -84,6 +85,13 @@ class SampleCameraImageLabel(QGraphicsView): self.__bounding_box = None self.__show_detections = True + self.__show_target_point = True + self.__show_target_coordinates = True + self.__target_point = None + self.__target_shape = None + self.__target_color_name = "Cyan" + self.__last_target_update_ts = 0.0 + self.__target_min_update_interval_s = 0.25 self.start_point = None # Starting point of the rectangle self.end_point = None # Ending point of the rectangle @@ -164,6 +172,21 @@ class SampleCameraImageLabel(QGraphicsView): self.__detections = [] self.update() + @Slot(dict) + def update_target_point(self, payload: dict): + now = time.monotonic() + if now - self.__last_target_update_ts < self.__target_min_update_interval_s: + return + + try: + self.__target_shape = payload.get("shape", self.__det_shape) + self.__target_point = payload.get("target_point") + self.__last_target_update_ts = now + except Exception as e: + logger.debug(f"Exception in update_target_point: {e}") + self.__target_point = None + self.update() + def __draw_busy_overlay(self, painter: QPainter): if not self.__is_daq_busy: @@ -298,6 +321,7 @@ class SampleCameraImageLabel(QGraphicsView): self.__draw_session_overlay(painter) self.__draw_camera_unavailable_overlay(painter) self.__draw_detections(painter, rect) + self.__draw_target_point(painter) def resizeEvent(self, event): super().resizeEvent(event) @@ -554,8 +578,6 @@ class SampleCameraImageLabel(QGraphicsView): elif self.__state == SampleCameraImageState.BEAM_MARKING: self.__state = SampleCameraImageState.IDLE - - def __left_single_click(self, event): if not self.__camera_interaction_enabled(): logger.info("Ignoring click because sample camera feed is unavailable") @@ -652,12 +674,127 @@ class SampleCameraImageLabel(QGraphicsView): painter.setPen(QPen(QColor(255, 255, 255))) painter.drawText(QPoint(int(x1) + 2, int(y1 - 4)), f"{label} {conf:.2f}") + def __target_color(self) -> QColor: + color_map = { + "Cyan": QColor(0, 255, 255), + "Dark Blue": QColor(0, 70, 160), + "Dark Red": QColor(140, 25, 25), + } + return color_map.get(self.__target_color_name, QColor(0, 255, 255)) + + def __draw_target_point(self, painter: QPainter): + if not self.__show_target_point: + return + if self.__target_point is None: + return + if self.pixmap_item is None: + return + + pix = self.pixmap_item.pixmap() + if pix.isNull(): + return + + shape = self.__target_shape or self.__det_shape + if shape is None or len(shape) < 2: + return + + try: + img_h, img_w = int(shape[0]), int(shape[1]) + raw = self.__target_point + + if isinstance(raw, dict): + tx = float(raw["x"]) + ty = float(raw["y"]) + elif isinstance(raw, (list, tuple)) and len(raw) >= 2: + tx = float(raw[0]) + ty = float(raw[1]) + else: + return + except Exception as e: + logger.debug(f"Error parsing target point {self.__target_point}: {e}") + return + + sx = pix.width() / float(img_w) + sy = pix.height() / float(img_h) + + px = tx * sx + py = ty * sy + + color = self.__target_color() + glow = QColor(color) + glow.setAlpha(70) + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(glow)) + painter.drawEllipse(QPointF(px, py), 20, 20) + + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(color, 3, Qt.PenStyle.SolidLine)) + painter.drawEllipse(QPointF(px, py), 12, 12) + painter.setPen(QPen(QColor(255, 255, 255), 1, Qt.PenStyle.SolidLine)) + painter.drawEllipse(QPointF(px, py), 5, 5) + + painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine)) + painter.drawLine(QPointF(px - 20, py), QPointF(px - 7, py)) + painter.drawLine(QPointF(px + 7, py), QPointF(px + 20, py)) + painter.drawLine(QPointF(px, py - 20), QPointF(px, py - 7)) + painter.drawLine(QPointF(px, py + 7), QPointF(px, py + 20)) + + label_text = "Target" + if self.__show_target_coordinates: + label_text = f"Target ({tx:.1f}, {ty:.1f})" + + font = QFont() + font.setPointSize(10) + font.setBold(True) + painter.setFont(font) + + fm = QFontMetrics(font) + text_rect = fm.boundingRect(label_text) + bubble_rect = QRectF( + px + 16, + py - 28, + text_rect.width() + 16, + text_rect.height() + 10 + ) + + painter.setPen(QPen(color, 2)) + painter.setBrush(QColor(20, 20, 20, 190)) + painter.drawRoundedRect(bubble_rect, 8, 8) + + painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.drawText( + QPointF(bubble_rect.left() + 8, bubble_rect.top() + 7 + fm.ascent()), + label_text + ) + + painter.restore() + @Slot(bool) def set_show_detections(self, show: bool): """Slot to enable/disable showing ML detections""" self.__show_detections = show self.update() # Trigger redraw + @Slot(bool) + def set_show_target_point(self, show: bool): + """Slot to enable/disable showing target point""" + self.__show_target_point = show + self.update() + + @Slot(bool) + def set_show_target_coordinates(self, show: bool): + self.__show_target_coordinates = show + self.update() + + @Slot(str) + def set_target_color(self, color_name: str): + self.__target_color_name = color_name + self.update() + def __draw_ml_bounding_box(self, painter: QPainter): if self.__bounding_box is None: return