fix: sample camera ZMQ feed pacing, one busy badge color #204

Merged
duan_j merged 10 commits from fix/aaregui-zmq into main 2026-09-08 15:00:28 +02:00
9 changed files with 333 additions and 171 deletions
+30 -3
View File
@@ -32,8 +32,10 @@ from PySide6.QtGui import (
QColor,
QCursor,
QGuiApplication,
QImage,
QKeySequence,
QPalette,
QPixmap,
)
from PySide6.QtWidgets import (
QAbstractButton,
@@ -1049,9 +1051,7 @@ class MainWindow(QMainWindow):
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.image.connect(self.compact_sample_camera.update_pixmap)
self.prediction_thread.image.connect(self.portrait_sample_camera.update_pixmap)
self.prediction_thread.image.connect(self._on_sample_camera_frame)
self.prediction_thread.prediction.connect(self.sample_camera.update_detections)
self.prediction_thread.prediction.connect(self.compact_sample_camera.update_detections)
self.prediction_thread.prediction.connect(self.portrait_sample_camera.update_detections)
@@ -1609,6 +1609,33 @@ class MainWindow(QMainWindow):
settings.setValue("samcam/compact_overlay_legend", overlay["compact_overlay_legend"])
settings.setValue("samcam/target_color", overlay["target_color"])
@Slot(QImage)
def _on_sample_camera_frame(self, image: QImage) -> None:
"""Hand one frame to whichever sample camera view is on screen.
The subscriber emits from its own thread, so this runs queued on the
GUI thread. Painting only the visible view costs one repaint per frame
instead of three, and the acknowledgement at the end is what paces the
subscriber to the rate the GUI can actually keep up with.
The subscriber sends a QImage: QPixmap is a GUI-thread-only class, so
it is built here rather than in the worker.
"""
try:
pixmap = QPixmap.fromImage(image)
for view in (
self.sample_camera,
self.compact_sample_camera,
self.portrait_sample_camera,
):
if view.isVisible():
view.update_pixmap(pixmap)
finally:
# The slot is only connected once the subscriber exists, but the
# attribute is Optional so the guard is what the type checker needs.
if self.prediction_thread is not None:
self.prediction_thread.notify_frame_displayed()
@Slot(bool)
def _on_sample_camera_availability_changed(self, available: bool) -> None:
if available:
+1 -15
View File
@@ -471,9 +471,6 @@ LEGEND_BG = "#eff1f5" # base
LEGEND_TEXT = "#4c4f69" # text
TOOLTIP_TEXT = "#4c4f69" # camera coords tooltip pen — NOT the QToolTip popup
SCALE_BAR_GREY = "#8c8fa1" # hover HUD scale bar (Latte overlay1 grey)
MARK_TOOLTIP_GOLD = "#df8e1d" # yellow
MARK_TOOLTIP_ORANGE = "#fe640b" # peach
MARK_TOOLTIP_RED = "#d20f39" # red
MARK_BADGE_BG = "#fe640b" # peach
# Prediction class overlay colors. The old pure-green vs CSS-green split
@@ -503,22 +500,11 @@ BOOKMARK_COLORS = {
"lime": "#179299", # teal — Latte has one green; teal keeps the pair distinct
}
# -- Busy overlay (per-source color coding) ---------------------------------
# -- Busy overlay: PSI red for every busy state, blue for robot cooling ------
# Catppuccin Latte accents; BORDER/DOT are 25%/20% mixes toward Latte base.
BUSY_YELLOW = "#df8e1d" # yellow
BUSY_YELLOW_BORDER = "#ebd8bf"
BUSY_YELLOW_DOT = "#ecddca"
BUSY_YELLOW_TEXT_DARK = "#4c4f69" # text
BUSY_PURPLE = "#8839ef" # mauve
BUSY_PURPLE_BORDER = "#d5c3f4"
BUSY_PURPLE_DOT = "#daccf4"
BUSY_RED_BADGE = "#d20f39" # red
BUSY_RED_FILL = "#d20f39" # red
BUSY_RED_BORDER = "#e8b8c6"
BUSY_RED_DOT = "#e9c4cf"
BUSY_ORANGE = "#fe640b" # peach
BUSY_ORANGE_BORDER = "#f3ceba"
BUSY_ORANGE_DOT = "#f2d5c6"
BUSY_BLUE = "#1e66f5" # blue
BUSY_BLUE_BORDER = "#bbcef5"
BUSY_BLUE_DOT = "#c5d5f5"
+76 -9
View File
@@ -8,7 +8,7 @@ from aarecommon.config.logger import setup_logger
from aarecommon.math.autofocus import focus_measure_edges
from aarecommon.models.models import DAQStatusModel
from PySide6.QtCore import QThread, Signal, Slot
from PySide6.QtGui import QImage, QPixmap
from PySide6.QtGui import QImage
from aare.gui.constants import LOGGER_NAME
@@ -18,18 +18,29 @@ logger = setup_logger(LOGGER_NAME)
class PredictionSubscriber(QThread):
prediction = Signal(dict)
target_point = Signal(dict)
image = Signal(QPixmap)
image = Signal(QImage)
focus_measure = Signal(float)
fps_measure = Signal(float)
camera_availability_changed = Signal(bool)
camera_error = Signal(str)
#: How many frames may sit unpainted in the GUI event queue (see _emit_image).
MAX_FRAMES_IN_FLIGHT = 2
def __init__(self, pred_zmq_url: str, topic: bytes | str = b"", parent=None):
super().__init__(parent)
self._ctx = zmq.Context()
self._sock = self._ctx.socket(zmq.SUB)
self._sock.setsockopt(zmq.RCVTIMEO, 500)
self._sock.setsockopt(zmq.LINGER, 0)
# Cap the backlog the socket may hold. Both must be set before connect()
# to take effect on the pipe. Defaults let the SUB queue grow to 1000
# messages (20 s of video at 50 Hz) with megabytes more hidden in the
# kernel buffer, which is the backlog _recv_latest would then have to
# throw away every frame. RCVBUF stays well above the ~2.5 MB/s the
# stream actually needs, so it costs no throughput.
self._sock.setsockopt(zmq.RCVHWM, 4)
self._sock.setsockopt(zmq.RCVBUF, 512 * 1024)
self._emit_images = True
self.running = True
@@ -50,6 +61,9 @@ class PredictionSubscriber(QThread):
self._camera_available = False
self._last_camera_error: str | None = None
self._dropped = 0 # stale frames skipped since the last fps report
self._frames_in_flight = 0
if isinstance(topic, str):
self._sock.setsockopt_string(zmq.SUBSCRIBE, topic)
elif isinstance(topic, bytes):
@@ -132,9 +146,34 @@ class PredictionSubscriber(QThread):
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 _rgb_to_qimage(self, rgb: np.ndarray) -> QImage:
# .copy() because QImage does not own the numpy buffer it wraps.
return QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy()
def _emit_image(self, image: QImage) -> None:
"""Emit a frame only while the GUI is keeping up, dropping it otherwise.
`image` is a queued cross-thread signal and Qt's event queue has no
high-water mark, so if the GUI paints slower than frames arrive the
queue grows without bound and the picture on screen falls further
behind for as long as the stream runs. Capping the frames in flight
paces the feed to whatever the GUI can actually sustain.
The cap is 2 rather than 1 so that one frame can be queued while the
GUI paints the other: at 1 the GUI idles until the worker finishes
decoding the next frame. The second slot costs one frame of latency
(20 ms at 50 Hz) and buys back that idle time.
"""
if self._frames_in_flight >= self.MAX_FRAMES_IN_FLIGHT:
self._dropped += 1
return
self._frames_in_flight += 1
self.image.emit(image)
def notify_frame_displayed(self) -> None:
"""Called from the GUI thread once a frame has been handed to the views."""
self._frames_in_flight = max(0, self._frames_in_flight - 1)
def _emit_focus_measure_if_enabled(self, rgb: np.ndarray) -> None:
if not self._measure_focus:
@@ -151,13 +190,34 @@ class PredictionSubscriber(QThread):
sharpness = focus_measure_edges(gray, self._focus_mask)
self.focus_measure.emit(sharpness)
def _recv_latest(self) -> list[bytes]:
"""Return the newest queued message, discarding the backlog behind it.
zmq.CONFLATE is unusable here: it keeps only the last *part*, which
shreds multipart messages. recv_multipart is atomic though — a
non-blocking recv either yields a whole message or raises Again — so
draining in a loop is a multipart-safe conflate. The drain runs after
the (slow) decode of the previous frame, so it eats exactly the
backlog that piled up while we were busy, and the stream is always
consumed at line rate no matter how long a frame takes to decode.
"""
# Bind locally: run() nulls the attribute on cleanup, so it is Optional
# to the type checker even though it cannot be None while run() loops.
sock = self._sock
assert sock is not None, "_recv_latest called after the socket was closed"
parts = sock.recv_multipart() # honours RCVTIMEO, may raise zmq.Again
while True:
try:
parts = sock.recv_multipart(zmq.NOBLOCK)
except zmq.Again:
return parts
self._dropped += 1
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()
parts = self._recv_latest()
except zmq.Again:
now = time.perf_counter()
elapsed = now - self._fps_window_start
@@ -185,6 +245,13 @@ class PredictionSubscriber(QThread):
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))
if self._dropped:
logger.debug(
"Sample camera: skipped %d stale frame(s), showing %.1f fps",
self._dropped,
fps,
)
self._dropped = 0
self._fps_window_start = now
self._fps_frame_count = 0
@@ -221,7 +288,7 @@ class PredictionSubscriber(QThread):
self._set_camera_available(True)
self._emit_focus_measure_if_enabled(rgb)
if self.running:
self.image.emit(self._rgb_to_pixmap(rgb))
self._emit_image(self._rgb_to_qimage(rgb))
elif self._emit_images:
self._set_camera_available(
False, "Sample camera feed unavailable: no frame header in zmq stream"
+36 -82
View File
@@ -11,23 +11,12 @@ from aare.gui.styles import (
BUSY_BLUE,
BUSY_BLUE_BORDER,
BUSY_BLUE_DOT,
BUSY_ORANGE,
BUSY_ORANGE_BORDER,
BUSY_ORANGE_DOT,
BUSY_PSI_RED,
BUSY_PSI_RED_BORDER,
BUSY_PSI_RED_DOT,
BUSY_PURPLE,
BUSY_PURPLE_BORDER,
BUSY_PURPLE_DOT,
BUSY_RED_BADGE,
BUSY_RED_BORDER,
BUSY_RED_DOT,
BUSY_RED_FILL,
BUSY_YELLOW,
BUSY_YELLOW_BORDER,
BUSY_YELLOW_DOT,
BUSY_YELLOW_TEXT_DARK,
SHADOW,
WHITE,
qcolor,
@@ -172,6 +161,21 @@ def draw_busy_status_text(
painter.drawText(QPoint(x, baseline), style.text)
# The one "not yours" badge. Module-level so the sample camera can paint the
# same box while the session state is still unknown (no status yet), instead
# of a differently colored hand-rolled one.
VIEWING_MODE_STYLE = BusyOverlayStyle(
text="Viewing mode, Click here to grab the baton",
badge_bg=BUSY_YELLOW,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_YELLOW_DOT,
subtext="Grab the baton if you need to interact with GUI",
)
def build_busy_overlay_style(
*,
is_busy: bool,
@@ -184,16 +188,7 @@ def build_busy_overlay_style(
SessionsStateEnum.PendingYouToElse,
SessionsStateEnum.Vacant,
}:
return BusyOverlayStyle(
text="Viewing mode, Click here to grab the baton",
badge_bg=BUSY_YELLOW,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_YELLOW_DOT,
subtext="Grab the baton if you need to interact with GUI",
)
return VIEWING_MODE_STYLE
# Auto loop centering is busy + SampleAlignment in /status — the exact
# combo the callers hide (manual omega moves look identical), so it
@@ -201,77 +196,36 @@ def build_busy_overlay_style(
# automation progress says the Center step is running. Checked before
# is_busy because the callers pass is_busy=False during alignment.
if auto_centering:
return BusyOverlayStyle(
text="AUTO CENTERING",
badge_bg=BUSY_PURPLE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_PURPLE, 190),
overlay_border=qcolor(BUSY_PURPLE_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_PURPLE_DOT,
animate=True,
)
return _animated("AUTO CENTERING", BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT)
if not is_busy:
return None
activity_value = str(getattr(getattr(tell_state, "activity", None), "value", "") or "").lower()
if activity_value == "mounting":
return BusyOverlayStyle(
text="ROBOT MOUNTING",
badge_bg=BUSY_RED_BADGE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_RED_FILL, 185),
overlay_border=qcolor(BUSY_RED_BORDER, 230),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_RED_DOT,
animate=True,
)
if activity_value == "unmounting":
return BusyOverlayStyle(
text="ROBOT UNMOUNTING",
badge_bg=BUSY_ORANGE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_ORANGE, 190),
overlay_border=qcolor(BUSY_ORANGE_BORDER, 230),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_ORANGE_DOT,
animate=True,
)
if activity_value == "drying":
return BusyOverlayStyle(
text="ROBOT DRYING",
badge_bg=BUSY_YELLOW,
badge_fg=BUSY_YELLOW_TEXT_DARK,
overlay_fill=qcolor(BUSY_YELLOW, 195),
overlay_border=qcolor(BUSY_YELLOW_BORDER, 235),
overlay_text=qcolor(BUSY_YELLOW_TEXT_DARK),
accent_dot=BUSY_YELLOW_DOT,
animate=True,
)
# Cooling is the one robot phase that keeps its own (blue) color: it is
# long, harmless and the operator should read it as "wait" not "danger".
# Every other busy state shares the PSI red so the badge reads as one
# "hands off" signal instead of a per-activity rainbow.
if activity_value == "cooling":
return BusyOverlayStyle(
text="ROBOT COOLING",
badge_bg=BUSY_BLUE,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_BLUE, 190),
overlay_border=qcolor(BUSY_BLUE_BORDER, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_BLUE_DOT,
animate=True,
)
return _animated("ROBOT COOLING", BUSY_BLUE, BUSY_BLUE_BORDER, BUSY_BLUE_DOT)
text = {
"mounting": "ROBOT MOUNTING",
"unmounting": "ROBOT UNMOUNTING",
"drying": "ROBOT DRYING",
}.get(activity_value, "BEAMLINE BUSY")
return _animated(text, BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT)
def _animated(text: str, base: str, border: str, dot: str) -> BusyOverlayStyle:
return BusyOverlayStyle(
text="BEAMLINE BUSY",
badge_bg=BUSY_PSI_RED,
text=text,
badge_bg=base,
badge_fg=WHITE,
overlay_fill=qcolor(BUSY_PSI_RED, 195),
overlay_border=qcolor(BUSY_PSI_RED_BORDER, 235),
overlay_fill=qcolor(base, 190),
overlay_border=qcolor(border, 235),
overlay_text=qcolor(WHITE),
accent_dot=BUSY_PSI_RED_DOT,
accent_dot=dot,
animate=True,
)
+28 -62
View File
@@ -50,9 +50,6 @@ from aare.gui.styles import (
LEGEND_BG,
LEGEND_TEXT,
MARK_BADGE_BG,
MARK_TOOLTIP_GOLD,
MARK_TOOLTIP_ORANGE,
MARK_TOOLTIP_RED,
MARKER_GREEN,
PATH_END,
PATH_START,
@@ -65,6 +62,7 @@ from aare.gui.styles import (
qcolor,
)
from aare.gui.widgets.busy_overlay import (
VIEWING_MODE_STYLE,
BusyOverlayStyle,
build_busy_overlay_style,
draw_busy_badge,
@@ -374,27 +372,31 @@ class SampleCameraImageLabel(QGraphicsView):
painter.restore()
return
# SESSION VACANT / GUEST MODE badges double as the click target for the
# grab/request menu, same as the _draw_session_overlay badge they hide.
session_badge = self._session_state in (
SessionsStateEnum.Vacant,
SessionsStateEnum.OwnedByElse,
SessionsStateEnum.PendingYouToElse,
# The session badge doubles as the click target for the grab/request
# menu, same as the _draw_session_overlay badge it hides.
self._session_badge_rect = draw_busy_badge(
painter,
self.viewport().width(),
self.viewport().height(),
style,
fill=self._badge_fill(style),
)
fill = QColor(style.overlay_fill)
if session_badge and self._session_badge_hovered:
# Hover: darker in the light themes, brighter in Sunset.
fill = fill.lighter(125) if self._dark_theme else fill.darker(115)
bg_rect = draw_busy_badge(
painter, self.viewport().width(), self.viewport().height(), style, fill=fill
)
self._session_badge_rect = bg_rect if session_badge else None
painter.restore()
def _badge_fill(self, style: BusyOverlayStyle) -> QColor:
fill = QColor(style.overlay_fill)
if self._session_badge_hovered:
# Hover: darker in the light themes, brighter in Sunset.
fill = fill.lighter(125) if self._dark_theme else fill.darker(115)
return fill
def _draw_session_overlay(self, painter: QPainter):
"""Session badge for when the busy overlay has nothing to draw, i.e.
the session state is still unknown (no status yet, or one without a
session). Same yellow "viewing mode" badge as the known not-owned
states, so the click target looks identical whichever path paints it
(it used to be a gold/orange/red "Guest Mode" box of its own)."""
if self._busy_overlay_style is not None:
# Busy overlay drew (and owns) the session badge rect — don't clobber.
return
@@ -408,49 +410,13 @@ class SampleCameraImageLabel(QGraphicsView):
painter.save()
painter.resetTransform()
font = QFont()
font.setPointSize(24)
font.setBold(True)
painter.setFont(font)
if self._session_state == SessionsStateEnum.Vacant:
bg_color = qcolor(MARK_TOOLTIP_GOLD, 180)
text = "Session Vacant"
elif self._session_state == SessionsStateEnum.PendingYouToElse:
bg_color = qcolor(MARK_TOOLTIP_ORANGE, 150)
text = "Baton Requested..."
else:
bg_color = qcolor(MARK_TOOLTIP_RED, 150)
text = "Guest Mode"
fm = QFontMetrics(font)
text_rect = fm.boundingRect(text)
padding = 16
vw = self.viewport().width()
vh = self.viewport().height()
bg_w = text_rect.width() + 2 * padding
bg_h = text_rect.height() + 2 * padding
position_x = int((vw - bg_w) / 2)
position_y = int((vh - bg_h) / 2)
bg_rect = QRect(position_x, position_y, bg_w, bg_h)
# Clicking the badge opens the session (grab/request) menu.
self._session_badge_rect = bg_rect
if self._session_badge_hovered:
# Same hover polarity as the busy-overlay badge.
bg_color = bg_color.lighter(125) if self._dark_theme else bg_color.darker(115)
painter.setPen(QPen(qcolor(WHITE, 220)))
painter.setBrush(bg_color)
painter.drawRoundedRect(bg_rect, 10, 10)
painter.setPen(QPen(qcolor(WHITE)))
painter.drawText(QPoint(position_x + padding, position_y + padding + fm.ascent()), text)
self._session_badge_rect = draw_busy_badge(
painter,
self.viewport().width(),
self.viewport().height(),
VIEWING_MODE_STYLE,
fill=self._badge_fill(VIEWING_MODE_STYLE),
)
painter.restore()
def _draw_camera_unavailable_overlay(self, painter: QPainter):
+25
View File
@@ -50,3 +50,28 @@ def test_auto_centering_flag_overrides_alignment_gate():
auto_centering=True,
)
assert viewing is not None and viewing.text.startswith("Viewing mode")
def test_robot_cooling_is_the_only_blue_busy_state():
from types import SimpleNamespace
from typing import cast
from aarecommon.models.tell import TellStateModel
from aare.gui.styles import BUSY_BLUE, BUSY_PSI_RED
def _style(activity: str):
# Only .activity.value is read; cast keeps basedpyright off the stand-in.
tell = cast(
TellStateModel, cast(object, SimpleNamespace(activity=SimpleNamespace(value=activity)))
)
style = build_busy_overlay_style(is_busy=True, tell_state=tell)
assert style is not None
return style
assert _style("cooling").text == "ROBOT COOLING"
assert _style("cooling").badge_bg == BUSY_BLUE
# Every other busy state shares the one PSI red.
for activity in ("mounting", "unmounting", "drying", "unknown"):
assert _style(activity).badge_bg == BUSY_PSI_RED
assert _style("unknown").text == "BEAMLINE BUSY"
+8
View File
@@ -289,3 +289,11 @@ def test_autoscale_fits_from_the_first_frame(camera):
camera._autoscale = False
camera._scaling()
assert camera.transform().isIdentity()
def test_unknown_session_paints_the_viewing_mode_badge(camera):
# No status yet: the busy overlay has nothing, the session overlay must
# still hand out the same click target as the known not-owned states.
assert camera._busy_overlay_style is None
camera.grab()
assert camera._session_badge_rect is not None
+36
View File
@@ -606,3 +606,39 @@ def test_nonstaff_beamline_gate_popups(qtbot, mock_ui_state):
with patch("aare.gui.main_window.QToolTip") as tip:
qtbot.mousePress(aux_bar, Qt.MouseButton.LeftButton, pos=aux_bar.tabRect(1).center())
assert tip.showText.called, "aux-puck tab click must explain the lock"
def test_sample_camera_frame_paints_visible_views_and_acks(qtbot, mock_ui_state, monkeypatch):
from PySide6.QtGui import QImage
with (
patch("requests.get"),
patch("aare.gui.main_window.DAQWorker"),
patch("aare.gui.main_window.PredictionSubscriber"),
patch("aare.gui.main_window.VideoThread"),
patch("aare.gui.main_window.JFJochDBusClient"),
patch("aare.gui.main_window.jwt.decode") as mock_jwt,
):
mock_jwt.return_value = {
"sub": "testuser",
"staff": True,
"pgroups": ["p123"],
"session": 15,
}
win = _make_window(qtbot)
views = (win.sample_camera, win.compact_sample_camera, win.portrait_sample_camera)
# Nothing is shown in tests, so pretend every view is on screen to reach
# the paint branch.
monkeypatch.setattr(type(win.sample_camera), "isVisible", lambda self: True)
win.prediction_thread = MagicMock()
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
assert all(v.pixmap_item.pixmap().width() == 4 for v in views)
# The ack is what paces the subscriber; it must fire after every frame.
win.prediction_thread.notify_frame_displayed.assert_called_once()
# No subscriber (GUI started without a sample feed): the slot must not blow up.
win.prediction_thread = None
win._on_sample_camera_frame(QImage(4, 6, QImage.Format.Format_RGB888))
@@ -25,3 +25,96 @@ def test_json_dict_is_parsed():
def test_json_non_dict_is_rejected():
assert _parse(b"[1, 2]") is None
assert _parse(b"") is None
# --- Frame pacing and the multipart-safe conflate -------------------------
# Real socket on an inproc endpoint: the drain and the in-flight cap are the
# fix, so they run against libzmq rather than a mock.
import time
import cv2
import numpy as np
import pytest
import zmq
from PySide6.QtGui import QImage
@pytest.fixture
def sub(qapp):
s = PredictionSubscriber("inproc://samcam-test")
yield s
# run() nulls both on its own cleanup; only tear down what is left.
if s._sock is not None:
s._sock.close(0)
if s._ctx is not None:
s._ctx.term()
def test_emit_image_caps_frames_in_flight(sub):
image = QImage(4, 4, QImage.Format.Format_RGB888)
for _ in range(sub.MAX_FRAMES_IN_FLIGHT + 1):
sub._emit_image(image)
assert sub._frames_in_flight == sub.MAX_FRAMES_IN_FLIGHT
assert sub._dropped == 1
for _ in range(sub.MAX_FRAMES_IN_FLIGHT + 1):
sub.notify_frame_displayed()
assert sub._frames_in_flight == 0 # floors at zero, never negative
def test_recv_latest_keeps_only_the_newest_message(sub):
pub = sub._ctx.socket(zmq.PUB)
pub.bind("inproc://samcam-test")
try:
# Slow joiner: publish probes until the subscription has propagated.
deadline = time.monotonic() + 5
while True:
pub.send_multipart([b"probe"])
try:
sub._sock.recv_multipart(zmq.NOBLOCK)
break
except zmq.Again:
assert time.monotonic() < deadline, "subscription never propagated"
time.sleep(0.01)
while True: # flush any extra probes
try:
sub._sock.recv_multipart(zmq.NOBLOCK)
except zmq.Again:
break
sub._dropped = 0
for payload in (b"old", b"stale", b"newest"):
pub.send_multipart([b"hdr", payload])
assert sub._recv_latest() == [b"hdr", b"newest"]
assert sub._dropped == 2
finally:
pub.close(0)
def test_run_decodes_one_frame_and_reports_the_drops(sub, qtbot):
ok, jpeg = cv2.imencode(".jpg", np.zeros((4, 6, 3), dtype=np.uint8))
assert ok
frame = [b'{"encoding": "jpeg"}', jpeg.tobytes()]
calls = []
def one_frame_then_stop():
calls.append(1)
if len(calls) == 1:
return frame
sub.running = False
raise zmq.Again
sub._recv_latest = one_frame_then_stop # type: ignore[method-assign]
sub._fps_window_start = time.perf_counter() - 10 # force the fps report
sub._dropped = 3
images = []
sub.image.connect(images.append)
sub.run()
assert len(images) == 1 and images[0].width() == 6 and images[0].height() == 4
assert sub._frames_in_flight == 1
assert sub._dropped == 0 # reported alongside the fps, then reset
assert sub._camera_available
assert sub._sock is None and sub._ctx is None # run() cleaned up