axis_camera: added refeesh button and busy overlay. Made sperate widget/panel as "default" axis camera tab
This commit is contained in:
@@ -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:
|
||||
|
||||
+119
-81
@@ -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:
|
||||
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user