From 2e056065edddcd2e385e5bc7c49a872f59875bfc Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 6 Aug 2026 18:05:00 +0200 Subject: [PATCH] feat(plots): enable pyqtgraph OpenGL viewport for curve-based plots --- bec_widgets/utils/bec_widget.py | 7 +- bec_widgets/utils/gpu_acceleration.py | 184 ++++++++++++++++++ .../plots/multi_waveform/multi_waveform.py | 2 + bec_widgets/widgets/plots/plot_base.py | 13 ++ .../widgets/plots/waveform/waveform.py | 2 + gpu_bench.py | 75 +++++++ tests/unit_tests/test_gpu_acceleration.py | 116 +++++++++++ 7 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 bec_widgets/utils/gpu_acceleration.py create mode 100644 gpu_bench.py create mode 100644 tests/unit_tests/test_gpu_acceleration.py diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index fb7b172a..1bf750b2 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -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.") diff --git a/bec_widgets/utils/gpu_acceleration.py b/bec_widgets/utils/gpu_acceleration.py new file mode 100644 index 00000000..30b671d0 --- /dev/null +++ b/bec_widgets/utils/gpu_acceleration.py @@ -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 diff --git a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py index 291a15cc..1702a56f 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -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 diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index b2c494b1..684cf43c 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -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) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index afc4398b..40cc1837 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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", diff --git a/gpu_bench.py b/gpu_bench.py new file mode 100644 index 00000000..7f22a1ed --- /dev/null +++ b/gpu_bench.py @@ -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") diff --git a/tests/unit_tests/test_gpu_acceleration.py b/tests/unit_tests/test_gpu_acceleration.py new file mode 100644 index 00000000..de96c5bc --- /dev/null +++ b/tests/unit_tests/test_gpu_acceleration.py @@ -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