diff --git a/docs/source/user_guide/gpu.rst b/docs/source/user_guide/gpu.rst index 006f78872..18fb81cdf 100644 --- a/docs/source/user_guide/gpu.rst +++ b/docs/source/user_guide/gpu.rst @@ -33,39 +33,18 @@ View available GPU You can view all GPUs that are available to ``WGPU`` like this:: - from wgpu.backends.wgpu_native import enumerate_adapters - from pprint import pprint + import wgpu - for adapter in enumerate_adapters(): - pprint(adapter.request_adapter_info()) + for adapter in wgpu.gpu.enumerate_adapters(): + print(adapter.summary) For example, on a Thinkpad AMD laptop with a dedicated nvidia GPU this returns:: - {'adapter_type': 'IntegratedGPU', - 'architecture': '', - 'backend_type': 'Vulkan', - 'description': 'Mesa 22.3.6', - 'device': 'AMD Radeon Graphics (RADV REMBRANDT)', - 'vendor': 'radv'} - {'adapter_type': 'DiscreteGPU', - 'architecture': '', - 'backend_type': 'Vulkan', - 'description': '535.129.03', - 'device': 'NVIDIA T1200 Laptop GPU', - 'vendor': 'NVIDIA'} - {'adapter_type': 'CPU', - 'architecture': '', - 'backend_type': 'Vulkan', - 'description': 'Mesa 22.3.6 (LLVM 15.0.6)', - 'device': 'llvmpipe (LLVM 15.0.6, 256 bits)', - 'vendor': 'llvmpipe'} - {'adapter_type': 'Unknown', - 'architecture': '', - 'backend_type': 'OpenGL', - 'description': '', - 'device': 'AMD Radeon Graphics (rembrandt, LLVM 15.0.6, DRM 3.52, ' - '6.4.0-0.deb12.2-amd64)', - 'vendor': ''} + AMD Radeon Graphics (RADV REMBRANDT) (IntegratedGPU) on Vulkan + NVIDIA T1200 Laptop GPU (DiscreteGPU) on Vulkan + llvmpipe (LLVM 15.0.6, 256 bits) (CPU) on Vulkan + AMD Radeon Graphics (rembrandt, LLVM 15.0.6, DRM 3.52, 6.4.0-0.deb12.2-amd64) (Unknown) on OpenGL + GPU currently in use -------------------- @@ -78,5 +57,5 @@ If you want to know the GPU that a current plot is using you can check the adapt plot.show() # GPU that is currently in use by the renderer - plot.renderer.device.adapter.request_adapter_info() + print(plot.renderer.device.adapter.summary) diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py index 27545f0ad..33db8c79d 100644 --- a/fastplotlib/__init__.py +++ b/fastplotlib/__init__.py @@ -5,21 +5,20 @@ from .graphics.selectors import * from .legends import * from .widgets import ImageWidget -from .utils import _notebook_print_banner, config +from .utils import config +from .utils.gui import run -from wgpu.gui.auto import run -from wgpu.backends.wgpu_native import enumerate_adapters +import wgpu with open(Path(__file__).parent.joinpath("VERSION"), "r") as f: __version__ = f.read().split("\n")[0] -adapters = [a.request_adapter_info() for a in enumerate_adapters()] +adapters = [a.summary for a in wgpu.gpu.enumerate_adapters()] if len(adapters) < 1: raise IndexError("No WGPU adapters found, fastplotlib will not work.") -_notebook_print_banner() __all__ = [ "Plot", diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index ff617c5e3..886ccbaaf 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -3,21 +3,19 @@ from numbers import Real import numpy as np - import pygfx -try: - import ipywidgets - - HAS_IPYWIDGETS = True -except (ImportError, ModuleNotFoundError): - HAS_IPYWIDGETS = False - +from ...utils.gui import IS_JUPYTER from .._base import Graphic, GraphicCollection from .._features._selection_features import LinearSelectionFeature from ._base_selector import BaseSelector +if IS_JUPYTER: + # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets + import ipywidgets + + class LinearSelector(BaseSelector): @property def limits(self) -> Tuple[float, float]: @@ -240,7 +238,7 @@ def make_ipywidget_slider(self, kind: str = "IntSlider", **kwargs): """ - if not HAS_IPYWIDGETS: + if not IS_JUPYTER: raise ImportError( "Must installed `ipywidgets` to use `make_ipywidget_slider()`" ) diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 4ffbd2cc2..b88174ddb 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -1,20 +1,18 @@ from typing import * from numbers import Real -try: - import ipywidgets - - HAS_IPYWIDGETS = True -except (ImportError, ModuleNotFoundError): - HAS_IPYWIDGETS = False - import numpy as np - import pygfx +from ...utils.gui import IS_JUPYTER from .._base import Graphic, GraphicCollection -from ._base_selector import BaseSelector from .._features._selection_features import LinearRegionSelectionFeature +from ._base_selector import BaseSelector + + +if IS_JUPYTER: + # If using the jupyter backend, user has jupyter_rfb, and thus also ipywidgets + import ipywidgets class LinearRegionSelector(BaseSelector): @@ -390,7 +388,7 @@ def make_ipywidget_slider(self, kind: str = "IntRangeSlider", **kwargs): """ - if not HAS_IPYWIDGETS: + if not IS_JUPYTER: raise ImportError( "Must installed `ipywidgets` to use `make_ipywidget_slider()`" ) diff --git a/fastplotlib/layouts/_frame/_frame.py b/fastplotlib/layouts/_frame/_frame.py index 2b76b8124..219a59082 100644 --- a/fastplotlib/layouts/_frame/_frame.py +++ b/fastplotlib/layouts/_frame/_frame.py @@ -1,41 +1,7 @@ import os -from ._toolbar import ToolBar - from ...graphics import ImageGraphic - -from .._utils import CANVAS_OPTIONS_AVAILABLE - - -class UnavailableOutputContext: - # called when a requested output context is not available - # ex: if trying to force jupyter_rfb canvas but jupyter_rfb is not installed - def __init__(self, context_name, msg): - self.context_name = context_name - self.msg = msg - - def __call__(self, *args, **kwargs): - raise ModuleNotFoundError( - f"The following output context is not available: {self.context_name}\n{self.msg}" - ) - - -# TODO: potentially put all output context and toolbars in their own module and have this determination done at import -if CANVAS_OPTIONS_AVAILABLE["jupyter"]: - from ._jupyter_output import JupyterOutputContext -else: - JupyterOutputContext = UnavailableOutputContext( - "Jupyter", - "You must install fastplotlib using the `'notebook'` option to use this context:\n" - 'pip install "fastplotlib[notebook]"', - ) - -if CANVAS_OPTIONS_AVAILABLE["qt"]: - from ._qt_output import QOutputContext -else: - QtOutput = UnavailableOutputContext( - "Qt", "You must install `PyQt6` to use this output context" - ) +from ._toolbar import ToolBar class Frame: @@ -158,6 +124,8 @@ def show( # return the appropriate OutputContext based on the current canvas if self.canvas.__class__.__name__ == "JupyterWgpuCanvas": + from ._jupyter_output import JupyterOutputContext # noqa - inline import + self._output = JupyterOutputContext( frame=self, make_toolbar=toolbar, @@ -167,6 +135,8 @@ def show( ) elif self.canvas.__class__.__name__ == "QWgpuCanvas": + from ._qt_output import QOutputContext # noqa - inline import + self._output = QOutputContext( frame=self, make_toolbar=toolbar, add_widgets=add_widgets ) diff --git a/fastplotlib/layouts/_frame/_qt_output.py b/fastplotlib/layouts/_frame/_qt_output.py index e8be2d050..d7e7f2612 100644 --- a/fastplotlib/layouts/_frame/_qt_output.py +++ b/fastplotlib/layouts/_frame/_qt_output.py @@ -1,5 +1,4 @@ -from PyQt6 import QtWidgets - +from ...utils.gui import QtWidgets from ._qt_toolbar import QToolbar diff --git a/fastplotlib/layouts/_frame/_qt_toolbar.py b/fastplotlib/layouts/_frame/_qt_toolbar.py index 4ee073701..d62994c2d 100644 --- a/fastplotlib/layouts/_frame/_qt_toolbar.py +++ b/fastplotlib/layouts/_frame/_qt_toolbar.py @@ -4,8 +4,7 @@ import traceback from typing import * -from PyQt6 import QtWidgets, QtCore - +from ...utils.gui import QtCore, QtWidgets from ...graphics.selectors import PolygonSelector from ._toolbar import ToolBar from ._qtoolbar_template import Ui_QToolbar diff --git a/fastplotlib/layouts/_frame/_qtoolbar_template.py b/fastplotlib/layouts/_frame/_qtoolbar_template.py index a8a1c6f86..d2311c595 100644 --- a/fastplotlib/layouts/_frame/_qtoolbar_template.py +++ b/fastplotlib/layouts/_frame/_qtoolbar_template.py @@ -5,8 +5,7 @@ # WARNING: Any manual changes made to this file will be lost when pyuic6 is # run again. Do not edit this file unless you know what you are doing. - -from PyQt6 import QtCore, QtGui, QtWidgets +from ...utils.gui import QtGui, QtCore, QtWidgets class Ui_QToolbar(object): @@ -30,7 +29,7 @@ def setupUi(self, QToolbar): self.maintain_aspect_button = QtWidgets.QPushButton(parent=QToolbar) font = QtGui.QFont() font.setBold(True) - font.setWeight(75) + font.setWeight(QtGui.QFont.Weight.Bold) self.maintain_aspect_button.setFont(font) self.maintain_aspect_button.setCheckable(True) self.maintain_aspect_button.setObjectName("maintain_aspect_button") diff --git a/fastplotlib/layouts/_gridplot.py b/fastplotlib/layouts/_gridplot.py index 04046cd01..fa987b661 100644 --- a/fastplotlib/layouts/_gridplot.py +++ b/fastplotlib/layouts/_gridplot.py @@ -6,7 +6,7 @@ import pygfx -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase from ._frame import Frame from ._utils import make_canvas_and_renderer, create_controller, create_camera @@ -22,7 +22,7 @@ def __init__( cameras: Union[str, list, np.ndarray] = "2d", controller_types: Union[str, list, np.ndarray] = None, controller_ids: Union[str, list, np.ndarray] = None, - canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, + canvas: Union[str, WgpuCanvasBase, pygfx.Texture] = None, renderer: pygfx.WgpuRenderer = None, size: Tuple[int, int] = (500, 300), names: Union[list, np.ndarray] = None, @@ -219,12 +219,6 @@ def __init__( for cam in cams[1:]: _controller.add_camera(cam) - if canvas is None: - canvas = WgpuCanvas() - - if renderer is None: - renderer = pygfx.renderers.WgpuRenderer(canvas) - self._canvas = canvas self._renderer = renderer @@ -266,7 +260,7 @@ def __init__( Frame.__init__(self) @property - def canvas(self) -> WgpuCanvas: + def canvas(self) -> WgpuCanvasBase: """The canvas associated to this GridPlot""" return self._canvas diff --git a/fastplotlib/layouts/_plot.py b/fastplotlib/layouts/_plot.py index 34027a276..44a880132 100644 --- a/fastplotlib/layouts/_plot.py +++ b/fastplotlib/layouts/_plot.py @@ -1,7 +1,7 @@ from typing import * import pygfx -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase from ._subplot import Subplot from ._frame import Frame @@ -11,7 +11,7 @@ class Plot(Subplot, Frame, RecordMixin): def __init__( self, - canvas: Union[str, WgpuCanvas] = None, + canvas: Union[str, WgpuCanvasBase] = None, renderer: pygfx.WgpuRenderer = None, camera: Union[str, pygfx.PerspectiveCamera] = "2d", controller: Union[str, pygfx.Controller] = None, diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 08a09baa7..2c93d7e9e 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -7,7 +7,7 @@ import pygfx from pylinalg import vec_transform, vec_unproject -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase from ._utils import create_camera, create_controller from ..graphics._base import Graphic @@ -29,7 +29,7 @@ def __init__( camera: Union[pygfx.PerspectiveCamera], controller: Union[pygfx.Controller], scene: pygfx.Scene, - canvas: WgpuCanvas, + canvas: WgpuCanvasBase, renderer: pygfx.WgpuRenderer, name: str = None, ): @@ -122,7 +122,7 @@ def scene(self) -> pygfx.Scene: return self._scene @property - def canvas(self) -> WgpuCanvas: + def canvas(self) -> WgpuCanvasBase: """Canvas associated to the plot area""" return self._canvas diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index e776fddb6..53d2a1400 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -4,7 +4,7 @@ import pygfx -from wgpu.gui.auto import WgpuCanvas +from wgpu.gui import WgpuCanvasBase from ..graphics import TextGraphic from ._utils import make_canvas_and_renderer, create_camera, create_controller @@ -20,7 +20,7 @@ def __init__( parent_dims: Tuple[int, int] = None, camera: Union[str, pygfx.PerspectiveCamera] = "2d", controller: Union[str, pygfx.Controller] = None, - canvas: Union[str, WgpuCanvas, pygfx.Texture] = None, + canvas: Union[str, WgpuCanvasBase, pygfx.Texture] = None, renderer: pygfx.WgpuRenderer = None, name: str = None, ): diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py index 1662f00c5..5ee930b67 100644 --- a/fastplotlib/layouts/_utils.py +++ b/fastplotlib/layouts/_utils.py @@ -1,64 +1,15 @@ from typing import * +import importlib import pygfx from pygfx import WgpuRenderer, Texture +from wgpu.gui import WgpuCanvasBase -# default auto-determined canvas -from wgpu.gui.auto import WgpuCanvas -from wgpu.gui.base import WgpuCanvasBase - - -# TODO: this determination can be better -try: - from wgpu.gui.jupyter import JupyterWgpuCanvas -except ImportError: - JupyterWgpuCanvas = False - -try: - import PyQt6 - from wgpu.gui.qt import QWgpuCanvas -except ImportError: - QWgpuCanvas = False - -try: - from wgpu.gui.glfw import GlfwWgpuCanvas -except ImportError: - GlfwWgpuCanvas = False - - -CANVAS_OPTIONS = ["jupyter", "glfw", "qt"] -CANVAS_OPTIONS_AVAILABLE = { - "jupyter": JupyterWgpuCanvas, - "glfw": GlfwWgpuCanvas, - "qt": QWgpuCanvas, -} - - -def auto_determine_canvas(): - try: - ip = get_ipython() - if ip.has_trait("kernel"): - if hasattr(ip.kernel, "app"): - if ip.kernel.app.__class__.__name__ == "QApplication": - return QWgpuCanvas - else: - return JupyterWgpuCanvas - except NameError: - pass - - else: - if CANVAS_OPTIONS_AVAILABLE["qt"]: - return QWgpuCanvas - elif CANVAS_OPTIONS_AVAILABLE["glfw"]: - return GlfwWgpuCanvas - - # We go with the wgpu auto guess - # for example, offscreen canvas etc. - return WgpuCanvas +from ..utils import gui def make_canvas_and_renderer( - canvas: Union[str, WgpuCanvas, Texture, None], renderer: [WgpuRenderer, None] + canvas: Union[str, WgpuCanvasBase, Texture, None], renderer: [WgpuRenderer, None] ): """ Parses arguments and returns the appropriate canvas and renderer instances @@ -66,23 +17,14 @@ def make_canvas_and_renderer( """ if canvas is None: - Canvas = auto_determine_canvas() - canvas = Canvas(max_fps=60) - + canvas = gui.WgpuCanvas(max_fps=60) elif isinstance(canvas, str): - if canvas not in CANVAS_OPTIONS: - raise ValueError(f"str canvas argument must be one of: {CANVAS_OPTIONS}") - elif not CANVAS_OPTIONS_AVAILABLE[canvas]: - raise ImportError( - f"The {canvas} framework is not installed for using this canvas" - ) - else: - canvas = CANVAS_OPTIONS_AVAILABLE[canvas](max_fps=60) - + m = importlib.import_module("wgpu.gui." + canvas) + canvas = m.WgpuCanvas(max_fps=60) elif not isinstance(canvas, (WgpuCanvasBase, Texture)): raise ValueError( f"canvas option must either be a valid WgpuCanvas implementation, a pygfx Texture" - f" or a str from the following options: {CANVAS_OPTIONS}" + f" or a str with the wgpu gui backend name." ) if renderer is None: diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py index 305af90a8..6759b2497 100644 --- a/fastplotlib/utils/__init__.py +++ b/fastplotlib/utils/__init__.py @@ -2,7 +2,6 @@ from .functions import * -from ._gpu_info import _notebook_print_banner @dataclass diff --git a/fastplotlib/utils/_gpu_info.py b/fastplotlib/utils/_gpu_info.py deleted file mode 100644 index 93e95d281..000000000 --- a/fastplotlib/utils/_gpu_info.py +++ /dev/null @@ -1,66 +0,0 @@ -from pathlib import Path - -from wgpu.backends.wgpu_native import enumerate_adapters -from wgpu.utils import get_default_device - -try: - ip = get_ipython() - from ipywidgets import Image - from wgpu.gui.jupyter import JupyterWgpuCanvas -except (NameError, ModuleNotFoundError, ImportError): - NOTEBOOK = False -else: - from IPython.display import display - - if ip.has_trait("kernel") and (JupyterWgpuCanvas is not False): - NOTEBOOK = True - else: - NOTEBOOK = False - - -def _notebook_print_banner(): - if NOTEBOOK is False: - return - - logo_path = Path(__file__).parent.parent.joinpath( - "assets", "fastplotlib_face_logo.png" - ) - - with open(logo_path, "rb") as f: - logo_data = f.read() - - image = Image(value=logo_data, format="png", width=300, height=55) - - display(image) - - # print logo and adapter info - adapters = [a for a in enumerate_adapters()] - adapters_info = [a.request_adapter_info() for a in adapters] - - ix_default = adapters_info.index( - get_default_device().adapter.request_adapter_info() - ) - - if len(adapters) > 0: - print("Available devices:") - - for ix, adapter in enumerate(adapters_info): - atype = adapter["adapter_type"] - backend = adapter["backend_type"] - driver = adapter["description"] - device = adapter["device"] - - if atype == "DiscreteGPU" and backend != "OpenGL": - charactor = chr(0x2705) - elif atype == "IntegratedGPU" and backend != "OpenGL": - charactor = chr(0x0001FBC4) - else: - charactor = chr(0x2757) - - if ix == ix_default: - default = " (default) " - else: - default = " " - - output_str = f"{charactor}{default}| {device} | {atype} | {backend} | {driver}" - print(output_str) diff --git a/fastplotlib/utils/gui.py b/fastplotlib/utils/gui.py new file mode 100644 index 000000000..b59c7799b --- /dev/null +++ b/fastplotlib/utils/gui.py @@ -0,0 +1,108 @@ +import sys +import importlib +from pathlib import Path + +import wgpu + + +# --- Prepare + + +# Ultimately, we let wgpu-py decide, but we can prime things a bit to create our +# own preferred order, by importing a Qt lib. But we only do this if no GUI has +# been imported yet. + +# Qt libs that we will try to import +qt_libs = ["PySide6", "PyQt6", "PySide2", "PyQt5"] + +# Other known libs that, if imported, we should probably not try to force qt +other_libs = ["glfw", "wx", "ipykernel"] + +already_imported = [name for name in (qt_libs + other_libs) if name in sys.modules] +if not already_imported: + for name in qt_libs: + try: + importlib.import_module(name) + except Exception: + pass + else: + break + + +# --- Triage + + +# Let wgpu do the auto gui selection +from wgpu.gui.auto import WgpuCanvas, run + +# Get the name of the backend ('qt', 'glfw', 'jupyter') +GUI_BACKEND = WgpuCanvas.__module__.split(".")[-1] +IS_JUPYTER = GUI_BACKEND == "jupyter" + + +# --- Some backend-specific preparations + + +def _notebook_print_banner(): + + from ipywidgets import Image + from IPython.display import display + + logo_path = Path(__file__).parent.parent.joinpath( + "assets", "fastplotlib_face_logo.png" + ) + + with open(logo_path, "rb") as f: + logo_data = f.read() + + image = Image(value=logo_data, format="png", width=300, height=55) + + display(image) + + # print logo and adapter info + adapters = [a for a in wgpu.gpu.enumerate_adapters()] + adapters_info = [a.request_adapter_info() for a in adapters] + + default_adapter_info = wgpu.gpu.request_adapter().request_adapter_info() + default_ix = adapters_info.index(default_adapter_info) + + if len(adapters) > 0: + print("Available devices:") + + for ix, adapter in enumerate(adapters_info): + atype = adapter["adapter_type"] + backend = adapter["backend_type"] + driver = adapter["description"] + device = adapter["device"] + + if atype == "DiscreteGPU" and backend != "OpenGL": + charactor = chr(0x2705) + elif atype == "IntegratedGPU" and backend != "OpenGL": + charactor = chr(0x0001FBC4) + else: + charactor = chr(0x2757) + + if ix == default_ix: + default = " (default) " + else: + default = " " + + output_str = f"{charactor}{default}| {device} | {atype} | {backend} | {driver}" + print(output_str) + + +if GUI_BACKEND == "jupyter": + _notebook_print_banner() + +elif GUI_BACKEND == "qt": + from wgpu.gui.qt import get_app, libname + + # create and store ref to qt app + _qt_app = get_app() + + # Import submodules of PySide6/PyQt6/PySid2/PyQt5 + # For the way that fpl uses Qt, the supported Qt libs seems compatible enough. + # If necessary we can do some qtpy-like monkey-patching here. + QtCore = importlib.import_module(".QtCore", libname) + QtGui = importlib.import_module(".QtGui", libname) + QtWidgets = importlib.import_module(".QtWidgets", libname) diff --git a/fastplotlib/widgets/image.py b/fastplotlib/widgets/image.py index 0da1bb520..9412f7cc5 100644 --- a/fastplotlib/widgets/image.py +++ b/fastplotlib/widgets/image.py @@ -8,14 +8,6 @@ from ..graphics import ImageGraphic from ..utils import calculate_gridshape from .histogram_lut import HistogramLUT -from ..layouts._utils import CANVAS_OPTIONS_AVAILABLE - - -if CANVAS_OPTIONS_AVAILABLE["jupyter"]: - from ..layouts._frame._ipywidget_toolbar import IpywidgetImageWidgetToolbar - -if CANVAS_OPTIONS_AVAILABLE["qt"]: - from ..layouts._frame._qt_toolbar import QToolbarImageWidget DEFAULT_DIMS_ORDER = { @@ -927,9 +919,17 @@ def show( ImageWidget just uses the Gridplot output context """ if self.gridplot.canvas.__class__.__name__ == "JupyterWgpuCanvas": + from ..layouts._frame._ipywidget_toolbar import ( + IpywidgetImageWidgetToolbar, + ) # noqa - inline import + self._image_widget_toolbar = IpywidgetImageWidgetToolbar(self) elif self.gridplot.canvas.__class__.__name__ == "QWgpuCanvas": + from ..layouts._frame._qt_toolbar import ( + QToolbarImageWidget, + ) # noqa - inline import + self._image_widget_toolbar = QToolbarImageWidget(self) self._output = self.gridplot.show( diff --git a/setup.py b/setup.py index a06a879a5..e8f2613d9 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ install_requires = [ "numpy>=1.23.0", + "wgpu>=0.15.1", "pygfx>=0.1.14", ]