fix: build the sample camera QPixmap on the GUI thread
CI / lint (push) Skipped
CI / test (3.12) (push) Skipped
CI / test (3.13) (push) Skipped
CI / test-with-beamline-plugins (pxi_bec) (push) Skipped
CI / test-with-beamline-plugins (pxii_bec) (push) Skipped
CI / test-with-beamline-plugins (pxiii_bec) (push) Skipped
CI / lint (pull_request) Canceled after 6s
CI / test (3.12) (pull_request) Canceled after 3s
CI / test (3.13) (pull_request) Canceled after 1s
CI / test (3.14) (pull_request) Canceled after 0s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Canceled after 0s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Canceled after 0s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Canceled after 0s
CI / test-with-coverage (pull_request) Canceled after 0s
CI / coverage-analysis (pull_request) Canceled after 0s

QPixmap is a GUI-thread-only class in Qt; the worker was constructing one per
frame via QPixmap.fromImage. It happens to work with the raster backend, which
is why this has not bitten us, but it is not supported and the guarantee is not
ours to rely on.

The subscriber now emits QImage — which is explicitly safe to build and move
between threads — and _on_sample_camera_frame converts once on the GUI thread
before handing the pixmap to the visible view. No extra copy: the .copy() that
detaches the QImage from the numpy buffer was already there.

Verified end to end against a real PUB socket: the payload delivered is QImage,
the slot runs only on the main thread, and frames still decode.

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:39:50 +02:00
co-authored by Claude Opus 5
parent eba0b47605
commit 443a25ebde
2 changed files with 15 additions and 10 deletions
+7 -2
View File
@@ -32,6 +32,7 @@ from PySide6.QtGui import (
QColor,
QCursor,
QGuiApplication,
QImage,
QKeySequence,
QPalette,
QPixmap,
@@ -1595,16 +1596,20 @@ 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:
@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,
@@ -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,7 +18,7 @@ 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)
@@ -146,11 +146,11 @@ 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, pixmap: QPixmap) -> None:
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
@@ -169,7 +169,7 @@ class PredictionSubscriber(QThread):
return
self._frames_in_flight += 1
self.image.emit(pixmap)
self.image.emit(image)
def notify_frame_displayed(self) -> None:
"""Called from the GUI thread once a frame has been handed to the views."""
@@ -284,7 +284,7 @@ class PredictionSubscriber(QThread):
self._set_camera_available(True)
self._emit_focus_measure_if_enabled(rgb)
if self.running:
self._emit_image(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"