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

This commit is contained in:
2026-08-14 12:04:43 +02:00
parent f77e5e108e
commit c6b58ff722
8 changed files with 535 additions and 3 deletions
+139
View File
@@ -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
~510k 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.
+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
@@ -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
+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.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)
@@ -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",
+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