fix: pace sample camera frames to what the GUI can paint

image is a queued cross-thread signal and Qt's event queue has no high-water
mark, so this queue grew without bound independently of ZMQ: with a 50 Hz feed
and a GUI painting slower, the displayed frame fell further behind for as long
as the stream ran. Measured on a stand-in at 15 ms decode / 25 ms paint, the
displayed frame aged from 40 ms to 2010 ms over three seconds and kept going.

Two changes:

- Emit once and fan out on the GUI thread to whichever view is actually on
  screen, rather than connecting all three SampleCameraImageLabels. Two of the
  three are always hidden (tab / compact page / portrait page), so this is one
  repaint per frame instead of three.

- Cap the frames in flight and drop rather than queue past it. The cap is 2,
  not 1, so a frame can be queued while the GUI paints the other: at 1 the GUI
  idles waiting for the next decode (33.3 fps displayed), at 2 it does not
  (40.0 fps) for one extra frame of latency. Both stay bounded — 40 ms and
  62 ms respectively, flat over the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVqbXPoeHyqrQq5Vc8EcYP
This commit is contained in:
2026-09-08 11:38:18 +02:00
co-authored by Claude Opus 5
parent 9c0c340b70
commit eba0b47605
2 changed files with 52 additions and 4 deletions
+22 -3
View File
@@ -34,6 +34,7 @@ from PySide6.QtGui import (
QGuiApplication,
QKeySequence,
QPalette,
QPixmap,
)
from PySide6.QtWidgets import (
QAbstractButton,
@@ -1042,9 +1043,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)
@@ -1596,6 +1595,26 @@ 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_sample_camera_frame(self, pixmap: QPixmap) -> 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.
"""
try:
for view in (
self.sample_camera,
self.compact_sample_camera,
self.portrait_sample_camera,
):
if view.isVisible():
view.update_pixmap(pixmap)
finally:
self.prediction_thread.notify_frame_displayed()
@Slot(bool)
def _on_sample_camera_availability_changed(self, available: bool) -> None:
if available:
+30 -1
View File
@@ -24,6 +24,9 @@ class PredictionSubscriber(QThread):
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()
@@ -59,6 +62,7 @@ class PredictionSubscriber(QThread):
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)
@@ -146,6 +150,31 @@ class PredictionSubscriber(QThread):
qimage = QImage(rgb.data, rgb.shape[1], rgb.shape[0], QImage.Format.Format_RGB888).copy()
return QPixmap.fromImage(qimage)
def _emit_image(self, pixmap: QPixmap) -> 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(pixmap)
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:
return
@@ -255,7 +284,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_pixmap(rgb))
elif self._emit_images:
self._set_camera_available(
False, "Sample camera feed unavailable: no frame header in zmq stream"