feat(plots): enable pyqtgraph OpenGL viewport for curve-based plots

This commit is contained in:
2026-08-10 16:21:38 +02:00
parent 63a094a721
commit 2e056065ed
7 changed files with 396 additions and 3 deletions
+4 -3
View File
@@ -16,6 +16,7 @@ import bec_widgets.widgets.containers.qt_ads as QtAds
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.busy_loader import install_busy_loader
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.gpu_acceleration import grab_widget
from bec_widgets.utils.rpc_decorator import rpc_timeout
from bec_widgets.utils.rpc_register import RPCRegister
from bec_widgets.utils.widget_io import WidgetHierarchy
@@ -280,7 +281,7 @@ class BECWidget(BECConnector):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
screenshot = grab_widget(self)
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
@@ -321,7 +322,7 @@ class BECWidget(BECConnector):
if not hasattr(self, "grab"):
raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}")
pixmap: QPixmap = self.grab()
pixmap: QPixmap = grab_widget(self)
if pixmap.isNull():
return QByteArray()
if max_width is not None or max_height is not None:
@@ -359,7 +360,7 @@ class BECWidget(BECConnector):
"SciLog is not enabled for the current client, cannot send screenshot."
)
pixmap: QPixmap = self.grab()
pixmap: QPixmap = grab_widget(self)
if pixmap.isNull():
raise RuntimeError("Failed to capture screenshot.")
+184
View File
@@ -0,0 +1,184 @@
"""OpenGL viewport support for BEC plots.
pyqtgraph 0.14 renders :class:`~pyqtgraph.PlotCurveItem` and
:class:`~pyqtgraph.PColorMeshItem` through a shader program whenever the
:class:`~pyqtgraph.GraphicsView` viewport is a ``QOpenGLWidget``. Every other
item -- notably ``ImageItem``, ``ScatterPlotItem``, ``TextItem`` and the ROIs --
keeps going through ``QPainter``, so only curve-heavy plots gain from it.
Acceleration is therefore opt-in per widget, and is additionally gated on the
context actually being hardware backed: a remote beamline session that lands on
a software rasteriser (llvmpipe/swrast) renders *slower* through OpenGL than
through the raster path.
The ``BEC_WIDGETS_OPENGL`` environment variable overrides the decision:
``auto`` (default)
Use OpenGL for widgets that ask for it, unless the renderer is software.
``1`` / ``on`` / ``true``
Force OpenGL on, even for a software renderer.
``0`` / ``off`` / ``false``
Never use OpenGL.
"""
from __future__ import annotations
import os
from functools import lru_cache
from bec_lib import bec_logger
from pyqtgraph import GraphicsView
from qtpy.QtCore import QPoint, QRectF, Qt
from qtpy.QtGui import QOffscreenSurface, QOpenGLContext, QPainter, QPixmap
from qtpy.QtOpenGLWidgets import QOpenGLWidget
from qtpy.QtWidgets import QApplication, QWidget
logger = bec_logger.logger
ENV_VAR = "BEC_WIDGETS_OPENGL"
# Substrings identifying a renderer that is not backed by a GPU. Mesa reports
# these when a session has no direct rendering, which is the common case for
# X-forwarded or VNC beamline consoles.
_SOFTWARE_RENDERERS = ("llvmpipe", "softpipe", "swrast", "software rasterizer", "gallium, swr")
_GL_VENDOR = 0x1F00
_GL_RENDERER = 0x1F01
_GL_VERSION = 0x1F02
@lru_cache(maxsize=1)
def opengl_info() -> dict[str, str] | None:
"""Query the OpenGL implementation backing this session.
Creates a throwaway context on an offscreen surface. The result is cached,
so the cost is paid once per process.
Returns:
dict[str, str] | None: ``vendor``/``renderer``/``version`` strings, or
None if no usable context could be created.
"""
if QApplication.instance() is None:
# A context needs a QApplication; asking this early is a caller bug, but
# it must not take the GUI down.
logger.warning("OpenGL probed before a QApplication exists; assuming unavailable")
return None
surface = QOffscreenSurface()
surface.create()
context = QOpenGLContext()
if not context.create() or not context.makeCurrent(surface):
logger.info("No usable OpenGL context; plots will use the raster viewport")
return None
try:
functions = context.functions()
info = {
"vendor": str(functions.glGetString(_GL_VENDOR)),
"renderer": str(functions.glGetString(_GL_RENDERER)),
"version": str(functions.glGetString(_GL_VERSION)),
}
finally:
context.doneCurrent()
logger.info(f"OpenGL renderer: {info['renderer']} ({info['vendor']}, {info['version']})")
return info
def is_software_renderer() -> bool:
"""Whether the OpenGL context is served by a software rasteriser."""
info = opengl_info()
if info is None:
return False
renderer = info["renderer"].lower()
return any(marker in renderer for marker in _SOFTWARE_RENDERERS)
def _env_override() -> bool | None:
"""Read ``BEC_WIDGETS_OPENGL``; None when unset or set to ``auto``."""
# Read on every call rather than caching, so tests and the launcher can flip
# it after bec_widgets has been imported.
raw = os.environ.get(ENV_VAR, "").strip().lower()
if raw in ("", "auto"):
return None
if raw in ("1", "on", "true", "yes"):
return True
if raw in ("0", "off", "false", "no"):
return False
logger.warning(f"Ignoring unrecognised {ENV_VAR}={raw!r}; expected auto, 1 or 0")
return None
def opengl_available(requested: bool = True) -> bool:
"""Decide whether a widget that asked for OpenGL should actually get it.
Args:
requested(bool): Whether the widget wants the OpenGL viewport at all.
Returns:
bool: True if the OpenGL viewport should be installed.
"""
override = _env_override()
if override is False:
return False
if not requested and override is not True:
return False
if opengl_info() is None:
return False
if override is True:
return True
if is_software_renderer():
logger.info(
"OpenGL is available but software rendered; keeping the raster viewport. "
f"Set {ENV_VAR}=1 to override."
)
return False
return True
def _accelerated_views(widget: QWidget) -> list[GraphicsView]:
"""GraphicsView descendants of ``widget`` that are on an OpenGL viewport."""
views = widget.findChildren(GraphicsView)
if isinstance(widget, GraphicsView):
views.append(widget)
return [v for v in views if v.isVisible() and isinstance(v.viewport(), QOpenGLWidget)]
def grab_widget(widget: QWidget) -> QPixmap:
"""Grab ``widget`` including any plots drawn on an OpenGL viewport.
``QWidget.grab`` renders the widget tree through ``QPainter`` and never
reaches a ``QOpenGLWidget``, so a plot on the OpenGL viewport comes back
blank. ``QOpenGLWidget.grabFramebuffer`` is not a reliable substitute
either -- on macOS the framebuffer is not retained after compositing.
Instead, each affected view re-renders its *scene* through ``QPainter``
into the grabbed pixmap. That is the same raster path the non-accelerated
plots already use, so the output matches, and no OpenGL state is touched.
Args:
widget(QWidget): The widget to capture.
Returns:
QPixmap: The captured pixmap, or the plain ``grab()`` result when no
OpenGL-backed plot is present.
"""
pixmap = widget.grab()
views = _accelerated_views(widget)
if not views or pixmap.isNull():
return pixmap
painter = QPainter(pixmap)
try:
for view in views:
viewport = view.viewport()
# GraphicsView.render forwards to QGraphicsView.render, which stretches
# the scene across the whole painter unless target and source are given.
origin = viewport.mapTo(widget, QPoint(0, 0))
source = viewport.rect()
target = QRectF(origin.x(), origin.y(), source.width(), source.height())
try:
view.render(painter, target, source, Qt.AspectRatioMode.IgnoreAspectRatio)
except Exception: # pragma: no cover - rendering must never break a screenshot
logger.warning(f"Failed to render {view!r} into screenshot", exc_info=True)
finally:
painter.end()
return pixmap
@@ -56,6 +56,8 @@ class MultiWaveform(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "ssid_chart"
# Many simultaneous curves; the biggest beneficiary of the shader path.
USE_OPENGL = True
USER_ACCESS = [
*PlotBase.USER_ACCESS,
# MultiWaveform Specific RPC Access
+13
View File
@@ -14,6 +14,7 @@ from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.entry_validator import EntryValidator
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.gpu_acceleration import opengl_available
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.side_panel import SidePanel
@@ -110,6 +111,13 @@ class PlotBase(BECWidget, QWidget):
]
USER_ACCESS = [*BECWidget.USER_ACCESS, *BASE_USER_ACCESS]
# Whether this plot benefits from the OpenGL viewport. Only PlotCurveItem and
# PColorMeshItem have a shader path in pyqtgraph 0.14; image- and scatter-based
# plots gain nothing, so they stay on the raster viewport. Subclasses that draw
# curves set this to True. The final say belongs to `opengl_available`, which
# also honours the BEC_WIDGETS_OPENGL environment variable.
USE_OPENGL = False
# Custom Signals
property_changed = Signal(str, object)
crosshair_position_changed = Signal(tuple)
@@ -154,6 +162,11 @@ class PlotBase(BECWidget, QWidget):
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
# GraphicsLayoutWidget forwards no viewport argument to GraphicsView, so the
# viewport is swapped after construction instead.
self._opengl_enabled = opengl_available(self.USE_OPENGL)
if self._opengl_enabled:
self.plot_widget.useOpenGL(True)
self.plot_widget.ci.setContentsMargins(0, 0, 0, 0)
self.plot_item = pg.PlotItem(viewBox=BECViewBox(enableMenu=True))
self.plot_widget.addItem(self.plot_item)
@@ -86,6 +86,8 @@ class Waveform(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "show_chart"
# Curves are drawn by PlotCurveItem, which has a shader path in pyqtgraph 0.14.
USE_OPENGL = True
USER_ACCESS = [
*PlotBase.USER_ACCESS,
"_config_dict",
+75
View File
@@ -0,0 +1,75 @@
"""Benchmark the pyqtgraph 0.14 OpenGL viewport against the raster path.
Measures *render* cost only: the curve data is generated up front and cycled
frame to frame, so numpy work does not pollute the timing. vsync is disabled
via the swap interval so the OpenGL path is not pinned to the display refresh.
python gpu_bench.py [n_points] [n_curves]
"""
import sys
import time
import numpy as np
import pyqtgraph as pg
from qtpy import QtCore, QtGui, QtWidgets
N_POINTS = int(sys.argv[1]) if len(sys.argv) > 1 else 100_000
N_CURVES = int(sys.argv[2]) if len(sys.argv) > 2 else 3
FRAMES = 60
N_PRESET = 10
def _bench(use_opengl: bool, label: str, datasets, x) -> float:
pg.setConfigOption("useOpenGL", use_opengl)
pg.setConfigOption("enableExperimental", use_opengl)
win = pg.GraphicsLayoutWidget()
win.resize(1200, 800)
plot = win.addPlot()
curves = []
for c in range(N_CURVES):
curve = pg.PlotDataItem(pen=pg.mkPen(pg.intColor(c), width=1))
plot.addItem(curve)
curves.append(curve)
plot.enableAutoRange(False)
plot.setXRange(0, N_POINTS)
plot.setYRange(-2, 2)
win.show()
deadline = time.perf_counter() + 1.0
while time.perf_counter() < deadline:
QtWidgets.QApplication.processEvents()
viewport = win.viewport().__class__.__name__
start = time.perf_counter()
for i in range(FRAMES):
for c, curve in enumerate(curves):
curve.setData(x, datasets[(i + c) % N_PRESET])
win.viewport().repaint()
QtWidgets.QApplication.processEvents()
elapsed = time.perf_counter() - start
win.close()
fps = FRAMES / elapsed
print(f" {label:<26} viewport={viewport:<22} {fps:7.1f} fps ({1000 / fps:6.1f} ms/frame)")
return fps
if __name__ == "__main__":
# disable vsync before the QApplication so QOpenGLWidget is not capped at ~60 Hz
fmt = QtGui.QSurfaceFormat.defaultFormat()
fmt.setSwapInterval(0)
QtGui.QSurfaceFormat.setDefaultFormat(fmt)
app = QtWidgets.QApplication(sys.argv)
print(f"pyqtgraph {pg.__version__} | Qt {QtCore.qVersion()} | swapInterval=0")
print(f"{N_CURVES} curves x {N_POINTS:,} points, {FRAMES} frames (data pre-generated)\n")
x = np.arange(N_POINTS, dtype=np.float64)
rng = np.random.default_rng(0)
datasets = [np.sin(x * 0.001 + k) + rng.normal(0, 0.1, N_POINTS) for k in range(N_PRESET)]
raster = _bench(False, "raster (QPainter)", datasets, x)
opengl = _bench(True, "opengl (QOpenGLWidget)", datasets, x)
print(f" -> speedup {opengl / raster:.2f}x")
+116
View File
@@ -0,0 +1,116 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtOpenGLWidgets import QOpenGLWidget
from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget
from bec_widgets.utils import gpu_acceleration
from bec_widgets.utils.gpu_acceleration import ENV_VAR, grab_widget, opengl_available
@pytest.fixture(autouse=True)
def _reset_opengl_probe(monkeypatch):
"""Keep the cached context probe and pyqtgraph's global config out of other tests."""
# hold on to the real cached function: monkeypatch may swap the module
# attribute for a stub, and it is only restored after this fixture resumes
probe = gpu_acceleration.opengl_info
probe.cache_clear()
monkeypatch.delenv(ENV_VAR, raising=False)
previous = pg.getConfigOption("useOpenGL")
yield
pg.setConfigOption("useOpenGL", previous)
probe.cache_clear()
def _fake_renderer(monkeypatch, renderer: str | None):
info = None if renderer is None else {"vendor": "v", "renderer": renderer, "version": "4.1"}
monkeypatch.setattr(gpu_acceleration, "opengl_info", lambda: info)
def test_opengl_available_requires_opt_in(monkeypatch):
_fake_renderer(monkeypatch, "NVIDIA GeForce RTX 3090")
assert opengl_available(requested=True) is True
assert opengl_available(requested=False) is False
def test_opengl_refused_without_context(monkeypatch):
_fake_renderer(monkeypatch, None)
assert opengl_available(requested=True) is False
@pytest.mark.parametrize("renderer", ["llvmpipe (LLVM 15.0.7, 256 bits)", "softpipe", "SWRast"])
def test_opengl_refused_on_software_renderer(monkeypatch, renderer):
"""A remote/X-forwarded session must stay on the raster viewport."""
_fake_renderer(monkeypatch, renderer)
assert opengl_available(requested=True) is False
def test_env_var_forces_opengl_on_software_renderer(monkeypatch):
_fake_renderer(monkeypatch, "llvmpipe (LLVM 15.0.7, 256 bits)")
monkeypatch.setenv(ENV_VAR, "1")
assert opengl_available(requested=True) is True
# forcing on also overrides a widget that did not ask for it
assert opengl_available(requested=False) is True
def test_env_var_disables_opengl(monkeypatch):
_fake_renderer(monkeypatch, "NVIDIA GeForce RTX 3090")
monkeypatch.setenv(ENV_VAR, "0")
assert opengl_available(requested=True) is False
def test_unrecognised_env_var_falls_back_to_auto(monkeypatch):
_fake_renderer(monkeypatch, "NVIDIA GeForce RTX 3090")
monkeypatch.setenv(ENV_VAR, "maybe")
assert opengl_available(requested=True) is True
def _non_background_fraction(pixmap) -> float:
"""Fraction of pixels differing from the most common colour."""
image = pixmap.toImage()
buffer = np.frombuffer(image.constBits(), dtype=np.uint8)
arr = buffer.reshape(image.height(), image.bytesPerLine() // 4, 4)
flat = arr[:, : image.width(), :3].reshape(-1, 3)
colours, counts = np.unique(flat, axis=0, return_counts=True)
return float(np.any(flat != colours[counts.argmax()], axis=1).mean())
def _plot_host(qtbot, use_opengl: bool):
pg.setConfigOption("useOpenGL", use_opengl)
host = QWidget()
layout = QVBoxLayout(host)
layout.addWidget(QLabel("scan 42"))
view = pg.GraphicsLayoutWidget()
layout.addWidget(view)
plot = view.addPlot()
x = np.arange(5_000, dtype=np.float64)
plot.addItem(pg.PlotDataItem(x, np.sin(x * 0.01), pen=pg.mkPen("r", width=2)))
plot.enableAutoRange(False)
plot.setXRange(0, 5_000)
plot.setYRange(-1.5, 1.5)
host.resize(640, 460)
qtbot.addWidget(host)
host.show()
qtbot.waitExposed(host)
return host, view
def test_grab_widget_matches_plain_grab_without_opengl(qtbot):
host, view = _plot_host(qtbot, use_opengl=False)
assert not isinstance(view.viewport(), QOpenGLWidget)
assert _non_background_fraction(grab_widget(host)) == pytest.approx(
_non_background_fraction(host.grab())
)
def test_grab_widget_recovers_plot_on_opengl_viewport(qtbot):
"""QWidget.grab() alone returns a blank plot area over an OpenGL viewport."""
host, view = _plot_host(qtbot, use_opengl=True)
if not isinstance(view.viewport(), QOpenGLWidget):
pytest.skip("no OpenGL viewport available in this environment")
plain = _non_background_fraction(host.grab())
composited = _non_background_fraction(grab_widget(host))
assert composited > plain
# the plot fills most of the host, so a correct capture is far from empty
assert composited > 0.05