diff --git a/GPU_ACCELERATION.md b/GPU_ACCELERATION.md new file mode 100644 index 00000000..83c78808 --- /dev/null +++ b/GPU_ACCELERATION.md @@ -0,0 +1,139 @@ +# GPU acceleration for BEC Widgets plots (pyqtgraph 0.14) + +Branch `pg-gpu`, worktree `bec_widgets_pg-gpu`, env `bec_312_pg-gpu`. + +## Summary + +**The migration is already done.** `pyproject.toml` pins `pyqtgraph==0.14.0` and that is what +`bec_312` has installed. There is no 0.13 → 0.14 port to carry out. + +**Enabling GPU acceleration is small** — one viewport swap at a single choke point — but it is +*not* free, and it does not help the widgets people usually assume it will. + +| | | +|---|---| +| Migration effort | none (already on 0.14.0) | +| Enablement effort | ~1 day incl. the screenshot fix and tests | +| Widgets that benefit | `Waveform`, `MultiWaveform` | +| Widgets that gain nothing | `Image`, `Heatmap`, `ScatterWaveform`, `MotorMap` | +| Main hazard | screenshots come back blank; software GL on remote consoles | + +## What pyqtgraph 0.14 actually accelerates + +0.14 rewrote the OpenGL path as a self-contained shader program (no PyOpenGL needed) and dropped +the `enableExperimental` gate. It applies to exactly two items: + +- `PlotCurveItem` — `paintGL` at `graphicsItems/PlotCurveItem.py:1013` +- `PColorMeshItem` — `paintGL` at `graphicsItems/PColorMeshItem.py:469` + +Everything else — `ImageItem`, `ScatterPlotItem`, `TextItem`, `InfiniteLine`, the ROIs, the axes — +has no `paintGL` and still goes through `QPainter`. There is no GPU path for image display in the +`QGraphicsView` stack at all. + +This matters because `ImageItem` is by far the most-used pyqtgraph item in this repo (27 call +sites vs. 9 for `PlotDataItem`). **The Image and Heatmap widgets get no benefit from this work.** + +## Measured + +Apple M1 Pro, PySide6 6.11.1, `swapInterval=0`, data pre-generated so only render cost is timed +(`gpu_bench.py`, 5 curves): + +| points/curve | raster | opengl | speedup | +|---:|---:|---:|---:| +| 1,000 | 170.1 fps | 319.1 fps | 1.9× | +| 5,000 | 74.3 fps | 292.3 fps | 3.9× | +| 20,000 | 29.5 fps | 248.1 fps | 8.4× | +| 100,000 | 6.7 fps | 155.5 fps | 23.2× | +| 500,000 | 1.4 fps | 51.4 fps | 36.5× | + +Raster cost scales with sample count; the OpenGL path stays roughly flat. Note the practical +threshold, though: with vsync on, a real app is capped at the display refresh anyway, so below +~5–10k points per curve both paths are already "fast enough" and the win is invisible. The change +pays off for long line scans and for `MultiWaveform`. + +For comparison, a 2048×2048 `ImageItem` measured 39.0 fps raster vs. 42.6 fps OpenGL — 1.09×, +i.e. noise. That is the expected result given there is no GL path for images. + +## The two real hazards + +### 1. Screenshots come back blank (fixed here) + +`BECWidget` captures via `self.grab()` in three places — `screenshot`, `screenshot_bytes` and +`screenshot_to_scilog`. `QWidget.grab()` renders the widget tree through `QPainter` and never +reaches a `QOpenGLWidget`, so the plot area is empty. Measured directly: 6.5% non-background +pixels on the raster viewport, **0.0%** on the OpenGL viewport. + +Left unfixed this silently uploads blank plots to SciLog. + +`QOpenGLWidget.grabFramebuffer()` is not a usable substitute — it also returned an empty image on +macOS, before and after a forced `repaint()`, because the framebuffer is not retained after +compositing. Swapping the viewport back to raster for the duration of the grab is worse: pyqtgraph +parents its `OpenGLState` to the GL viewport widget (`PlotCurveItem.py:49`), so destroying that +widget leaves `PlotCurveItem.glstate` dangling. + +The fix in `bec_widgets/utils/gpu_acceleration.py` re-renders the affected `GraphicsView`'s +*scene* through `QPainter` into the grabbed pixmap. No OpenGL state is touched and the output +matches the raster path. One subtlety: `GraphicsView.render` forwards to `QGraphicsView.render`, +not `QWidget.render`, so it stretches the scene across the whole painter unless an explicit target +and source rect are passed — without that the capture comes out zoomed. + +### 2. Software OpenGL on remote consoles + +The `renderer` string decides this. An X-forwarded or VNC session typically lands on Mesa +`llvmpipe`, where the OpenGL path is *slower* than raster. Given how BEC GUIs are deployed on +beamline nodes this is the common case, not the exotic one, so acceleration is gated on the +renderer not being software. + +## What was implemented + +- **`bec_widgets/utils/gpu_acceleration.py`** (new) — caches an offscreen-context probe of the GL + renderer, refuses software rasterisers, honours `BEC_WIDGETS_OPENGL=auto|1|0`, and provides + `grab_widget()` for OpenGL-safe screenshots. +- **`plot_base.py`** — `PlotBase.USE_OPENGL` class flag (default `False`); the viewport is swapped + after construction because `GraphicsLayoutWidget.__init__` forwards no viewport argument to + `GraphicsView`. +- **`waveform.py`, `multi_waveform.py`** — `USE_OPENGL = True`. +- **`bec_widget.py`** — the three `self.grab()` screenshot sites now call `grab_widget(self)`. +- **`tests/unit_tests/test_gpu_acceleration.py`** — 10 tests covering the renderer gate, the env + var, and that a capture over an OpenGL viewport is non-blank. + +Opt-in per widget rather than a global `pg.setConfigOption("useOpenGL", True)`, because a global +switch would put Image and Heatmap on a GL viewport for zero gain while still paying the blank-grab +and software-renderer costs. + +## Test status + +Full unit suite on this machine: **2051 passed, 3 skipped, 1 failed, 7 errors** (9m29s). None of +the failures are attributable to this change: + +- `test_client_utils.py::test_check_gui_display_available_reports_missing_display_for_ssh_session` + — pre-existing, macOS-only. `client_utils.py:72` returns `True, None` unconditionally when + `sys.platform == "darwin"`, so the assertion can only hold on Linux. +- 7 × `test_plugin_creator.py::TestAddWidgetVariants` — environmental. The copier template task + runs `pyside6-uic`, which exits 127 (not found) in the cloned `bec_312_pg-gpu` env. + +Targeted runs: 10/10 new tests, 167 passed across waveform/multi-waveform/plot_base/lifecycle/ +scatter, 322 passed in a plot/image/heatmap/crosshair/roi/export sweep. An `AttributeError` +traceback logged during `test_waveform.py` is pre-existing — it also appears with +`BEC_WIDGETS_OPENGL=0`. + +## Not addressed + +- **Antialiasing/appearance differences.** The GL path renders lines through its own shader; + hairlines and `antialias=True` will not be pixel-identical to raster. No reference-image tests + cover the plot canvas, so nothing failed, but it is worth an eyeball before deploying. +- **`ScatterWaveform` / `MotorMap`.** Left on raster. Their `ScatterPlotItem` has no GL path, so + accelerating them needs upstream work. +- **The `useOpenGL` viewport is still flagged experimental by Qt** for `QGraphicsView`. That is + Qt's wording, not a specific known bug. +- **Not verified on Linux/NVIDIA or on a real beamline console** — only on Apple M1 Pro. The + software-renderer gate is unit-tested with a faked renderer string, not against real llvmpipe. + +## Try it + +```bash +conda activate bec_312_pg-gpu && cd /Users/janwyzula/PSI/bec_widgets_pg-gpu && python gpu_bench.py 100000 5 +``` + +`gpu_risks.py` prints the grab-blankness comparison and the renderer identity for the current +session. 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 d68443cc..7bd17252 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -70,6 +70,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 6630f1ba..bd3c4b2d 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.qt_data_subscription import QtDataSubscription from bec_widgets.utils.round_frame import RoundedFrame @@ -116,6 +117,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) @@ -160,6 +168,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 f87f8c77..009fea03 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -136,6 +136,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