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", ]
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: