mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 00:42:37 +02:00
208 lines
7.7 KiB
Python
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
|