From eba0b476054e3777be7afdbbe4375ff9836249e4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 8 Sep 2026 11:38:18 +0200 Subject: [PATCH] fix: pace sample camera frames to what the GUI can paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01DVqbXPoeHyqrQq5Vc8EcYP --- src/aare/gui/main_window.py | 25 +++++++++++++-- src/aare/gui/threads/prediction_subscriber.py | 31 ++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 5a3187e8..6a3a6e46 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -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: diff --git a/src/aare/gui/threads/prediction_subscriber.py b/src/aare/gui/threads/prediction_subscriber.py index 42032d82..70a60be2 100644 --- a/src/aare/gui/threads/prediction_subscriber.py +++ b/src/aare/gui/threads/prediction_subscriber.py @@ -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"