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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user