feat(plots): expose use_opengl as a runtime-switchable property on PlotBase

This commit is contained in:
2026-08-14 12:44:47 +02:00
parent e010738ba4
commit 807eb10ffe
7 changed files with 312 additions and 15 deletions
+132
View File
@@ -2104,6 +2104,28 @@ class Heatmap(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -2832,6 +2854,28 @@ class Image(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -4025,6 +4069,28 @@ class MotorMap(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -4517,6 +4583,28 @@ class MultiWaveform(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -5819,6 +5907,28 @@ class ScatterWaveform(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -6488,6 +6598,28 @@ class Waveform(RPCBase):
``DEFAULT_UPDATE_RATE`` (25 Hz unless overridden).
"""
@property
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@use_opengl.setter
@rpc_call
def use_opengl(self) -> "bool":
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
+47
View File
@@ -134,6 +134,53 @@ def opengl_available(requested: bool = True) -> bool:
return True
def _release_gl_state(view: GraphicsView) -> None:
"""Drop the cached ``OpenGLState`` of every item in ``view``'s scene.
pyqtgraph creates that state once per item and parents it to the *viewport*
widget. Swapping the viewport deletes it on the C++ side while the item
keeps a stale Python reference, so the next ``paintGL`` raises
``RuntimeError: Signal source has been deleted``. ``PlotCurveItem.paint``
swallows it, which shows up as a silently missing curve rather than a
crash. Clearing the reference makes the item rebuild its state against the
new context.
"""
scene = view.scene()
if scene is None:
return
for item in scene.items():
# PlotDataItem delegates drawing to a PlotCurveItem held in .curve, which
# is itself in the scene; check both so nothing is missed.
for target in {item, getattr(item, "curve", None)}:
if target is None or getattr(target, "glstate", None) is None:
continue
signal = getattr(target, "sigPlotChanged", None)
if signal is not None:
try:
signal.disconnect(target.glstate.verticesChanged)
except (RuntimeError, TypeError):
# not connected, or the C++ side is already gone
pass
target.glstate = None
def set_view_opengl(view: GraphicsView, enabled: bool) -> bool:
"""Switch ``view`` between the OpenGL and raster viewport at runtime.
Args:
view(GraphicsView): The view whose viewport should be swapped.
enabled(bool): Whether the OpenGL viewport is wanted.
Returns:
bool: Whether the OpenGL viewport is active afterwards.
"""
if isinstance(view.viewport(), QOpenGLWidget) is enabled:
return enabled
_release_gl_state(view)
view.useOpenGL(enabled)
return isinstance(view.viewport(), QOpenGLWidget)
def _accelerated_views(widget: QWidget) -> list[GraphicsView]:
"""GraphicsView descendants of ``widget`` that are on an OpenGL viewport."""
views = widget.findChildren(GraphicsView)
@@ -70,8 +70,6 @@ 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
+35 -10
View File
@@ -6,6 +6,7 @@ import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from qtpy.QtCore import QPoint, QPointF, Qt, Signal
from qtpy.QtOpenGLWidgets import QOpenGLWidget
from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -14,7 +15,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.gpu_acceleration import opengl_available, set_view_opengl
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
@@ -113,16 +114,17 @@ class PlotBase(BECWidget, QWidget):
"minimal_crosshair_precision.setter",
"update_rate",
"update_rate.setter",
"use_opengl",
"use_opengl.setter",
"screenshot",
]
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
# Default for the `use_opengl` property. Curve-heavy plots gain the most, but
# the OpenGL viewport is no slower for the others, so it is on by default and
# can be toggled per widget at runtime. `opengl_available` still has the final
# say: it declines on a software renderer and honours BEC_WIDGETS_OPENGL.
USE_OPENGL = True
# Custom Signals
property_changed = Signal(str, object)
@@ -170,9 +172,7 @@ class PlotBase(BECWidget, QWidget):
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.use_opengl = self.USE_OPENGL
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)
@@ -323,6 +323,31 @@ class PlotBase(BECWidget, QWidget):
self.add_side_menus()
self.side_panel.show()
@SafeProperty(bool, doc="Render the plot through an OpenGL viewport.")
def use_opengl(self) -> bool:
"""
Whether the plot currently renders through an OpenGL viewport.
Reflects the live viewport rather than the requested value: setting this to
True is best effort, and stays False when no hardware-accelerated context is
available (see `bec_widgets.utils.gpu_acceleration.opengl_available`).
"""
return isinstance(self.plot_widget.viewport(), QOpenGLWidget)
@use_opengl.setter
def use_opengl(self, value: bool) -> None:
"""
Switch the plot between the OpenGL and raster viewport.
Args:
value(bool): Whether the OpenGL viewport is wanted.
"""
if value and not opengl_available(True):
if self.use_opengl:
set_view_opengl(self.plot_widget, False)
return
set_view_opengl(self.plot_widget, value)
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
def enable_popups(self):
"""
@@ -136,8 +136,6 @@ 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",
+62 -1
View File
@@ -5,7 +5,12 @@ 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
from bec_widgets.utils.gpu_acceleration import (
ENV_VAR,
grab_widget,
opengl_available,
set_view_opengl,
)
@pytest.fixture(autouse=True)
@@ -65,6 +70,62 @@ def test_unrecognised_env_var_falls_back_to_auto(monkeypatch):
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()
@@ -1,5 +1,9 @@
import numpy as np
import pytest
from qtpy.QtOpenGLWidgets import QOpenGLWidget
import bec_widgets.widgets.plots.plot_base as plot_base_module
from bec_widgets.utils.gpu_acceleration import opengl_available
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from .client_mocks import mocked_client
@@ -588,3 +592,35 @@ def test_update_rate_widget_defaults():
assert Waveform.DEFAULT_UPDATE_RATE == 15.0
assert Heatmap.DEFAULT_UPDATE_RATE == 5.0
assert Image.DEFAULT_UPDATE_RATE == 25.0
def test_use_opengl_defaults_to_true_when_available(qtbot, mocked_client):
"""PlotBase opts into the OpenGL viewport by default."""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
assert pb.USE_OPENGL is True
assert pb.use_opengl is opengl_available(True)
def test_use_opengl_can_be_toggled_at_runtime(qtbot, mocked_client):
"""The viewport can be swapped after the widget is built."""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
if not opengl_available(True):
pytest.skip("no hardware OpenGL available in this environment")
pb.use_opengl = False
assert pb.use_opengl is False
assert not isinstance(pb.plot_widget.viewport(), QOpenGLWidget)
pb.use_opengl = True
assert pb.use_opengl is True
assert isinstance(pb.plot_widget.viewport(), QOpenGLWidget)
def test_use_opengl_declines_without_hardware_context(qtbot, mocked_client, monkeypatch):
"""Requesting OpenGL on a software renderer leaves the raster viewport in place."""
pb = create_widget(qtbot, PlotBase, client=mocked_client)
monkeypatch.setattr(plot_base_module, "opengl_available", lambda requested=True: False)
pb.use_opengl = True
assert pb.use_opengl is False
assert not isinstance(pb.plot_widget.viewport(), QOpenGLWidget)