axis_camera: added refeesh button and busy overlay. Made sperate widget/panel as "default" axis camera tab

This commit is contained in:
appleb_m
2026-06-08 13:40:27 +02:00
parent 73b49829da
commit cbfb66949e
6 changed files with 422 additions and 124 deletions
+3
View File
@@ -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
View File
@@ -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:
+75
View File
@@ -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 "")
+20 -9
View File
@@ -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)
+148 -32
View File
@@ -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:
+57 -2
View File
@@ -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()