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) Failing after 38s
CI / test (3.12) (pull_request) Successful in 1m0s
CI / test (3.13) (pull_request) Successful in 1m2s
CI / test (3.14) (pull_request) Successful in 1m1s
CI / test-with-beamline-plugins (pxi_bec) (pull_request) Successful in 1m8s
CI / test-with-beamline-plugins (pxii_bec) (pull_request) Successful in 1m14s
CI / test-with-beamline-plugins (pxiii_bec) (pull_request) Successful in 1m19s
CI / test-with-coverage (pull_request) Successful in 1m26s
CI / coverage-analysis (pull_request) Failing after 3s

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 13:50:58 +02:00
committed by duan_j
co-authored by Claude Opus 5
parent a4a8b8029a
commit c842856fe0
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,
@@ -1608,16 +1609,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"