Files
bec_widgets/tests/unit_tests/test_gpu_acceleration.py
T

208 lines
7.7 KiB
Python

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,
set_view_opengl,
)
@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
notice = gpu_acceleration._log_software_renderer
probe.cache_clear()
notice.cache_clear()
monkeypatch.delenv(ENV_VAR, raising=False)
previous = pg.getConfigOption("useOpenGL")
yield
pg.setConfigOption("useOpenGL", previous)
probe.cache_clear()
notice.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_explicit_request_overrides_software_renderer(monkeypatch):
"""The use_opengl property must be able to force OpenGL on a remote console."""
_fake_renderer(monkeypatch, "llvmpipe (LLVM 15.0.7, 256 bits)")
assert opengl_available(requested=True) is False
assert opengl_available(requested=True, explicit=True) is True
def test_explicit_request_cannot_invent_a_context(monkeypatch):
_fake_renderer(monkeypatch, None)
assert opengl_available(requested=True, explicit=True) is False
def test_env_var_off_beats_an_explicit_request(monkeypatch):
_fake_renderer(monkeypatch, "NVIDIA GeForce RTX 3090")
monkeypatch.setenv(ENV_VAR, "0")
assert opengl_available(requested=True, explicit=True) is False
def test_software_renderer_notice_is_logged_once(monkeypatch, caplog):
"""opengl_available runs per widget; the notice must not repeat per plot."""
_fake_renderer(monkeypatch, "llvmpipe (LLVM 15.0.7, 256 bits)")
with caplog.at_level("INFO"):
for _ in range(5):
opengl_available(requested=True)
assert sum("software rendered" in r.message for r in caplog.records) <= 1
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 _curve_view(qtbot, use_opengl: bool):
pg.setConfigOption("useOpenGL", use_opengl)
view = pg.GraphicsLayoutWidget()
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)))
view.resize(400, 300)
qtbot.addWidget(view)
view.show()
qtbot.waitExposed(view)
return view
def test_set_view_opengl_toggles_viewport(qtbot):
view = _curve_view(qtbot, use_opengl=False)
if not gpu_acceleration.opengl_available(True):
pytest.skip("no hardware OpenGL available in this environment")
assert set_view_opengl(view, True) is True
assert isinstance(view.viewport(), QOpenGLWidget)
assert set_view_opengl(view, False) is False
assert not isinstance(view.viewport(), QOpenGLWidget)
def test_set_view_opengl_is_idempotent(qtbot):
view = _curve_view(qtbot, use_opengl=False)
viewport = view.viewport()
assert set_view_opengl(view, False) is False
# no needless swap: the same viewport object is kept
assert view.viewport() is viewport
def test_toggling_back_to_opengl_does_not_strand_gl_state(qtbot):
"""Swapping the viewport deletes the item's OpenGLState on the C++ side.
Without clearing the stale reference, the next paintGL raises
'Signal source has been deleted'. PlotCurveItem.paint swallows that, so the
curve silently stops rendering instead of crashing.
"""
view = _curve_view(qtbot, use_opengl=True)
if not isinstance(view.viewport(), QOpenGLWidget):
pytest.skip("no OpenGL viewport available in this environment")
curve = next(i for i in view.scene().items() if isinstance(i, pg.PlotCurveItem))
view.viewport().repaint()
assert curve.glstate is not None, "expected the GL path to have been taken"
set_view_opengl(view, False)
assert curve.glstate is None, "stale OpenGLState was not released on swap"
set_view_opengl(view, True)
view.viewport().repaint()
# rebuilt against the new context rather than reusing the deleted object
assert curve.glstate is not None
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