mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-06 16:40:56 +02:00
feat(plots): enable pyqtgraph OpenGL viewport for curve-based plots
This commit is contained in:
@@ -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.
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user