initial commit
This commit is contained in:
7
mne/viz/backends/__init__.py
Normal file
7
mne/viz/backends/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
"""Visualization backend."""
|
||||
|
||||
from . import renderer
|
||||
1500
mne/viz/backends/_abstract.py
Normal file
1500
mne/viz/backends/_abstract.py
Normal file
File diff suppressed because it is too large
Load Diff
1619
mne/viz/backends/_notebook.py
Normal file
1619
mne/viz/backends/_notebook.py
Normal file
File diff suppressed because it is too large
Load Diff
1358
mne/viz/backends/_pyvista.py
Normal file
1358
mne/viz/backends/_pyvista.py
Normal file
File diff suppressed because it is too large
Load Diff
1852
mne/viz/backends/_qt.py
Normal file
1852
mne/viz/backends/_qt.py
Normal file
File diff suppressed because it is too large
Load Diff
421
mne/viz/backends/_utils.py
Normal file
421
mne/viz/backends/_utils.py
Normal file
@@ -0,0 +1,421 @@
|
||||
#
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import collections.abc
|
||||
import functools
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import sys
|
||||
from colorsys import rgb_to_hls
|
||||
from contextlib import contextmanager
|
||||
from ctypes import c_char_p, c_void_p, cdll
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...fixes import _compare_version
|
||||
from ...utils import _check_qt_version, _validate_type, logger, warn
|
||||
from ..utils import _get_cmap
|
||||
|
||||
VALID_BROWSE_BACKENDS = (
|
||||
"qt",
|
||||
"matplotlib",
|
||||
)
|
||||
|
||||
VALID_3D_BACKENDS = (
|
||||
"pyvistaqt", # default 3d backend
|
||||
"notebook",
|
||||
)
|
||||
ALLOWED_QUIVER_MODES = ("2darrow", "arrow", "cone", "cylinder", "sphere", "oct")
|
||||
_ICONS_PATH = Path(__file__).parents[2] / "icons"
|
||||
|
||||
|
||||
def _get_colormap_from_array(
|
||||
colormap=None, normalized_colormap=False, default_colormap="coolwarm"
|
||||
):
|
||||
from matplotlib.colors import ListedColormap
|
||||
|
||||
if colormap is None:
|
||||
cmap = _get_cmap(default_colormap)
|
||||
elif isinstance(colormap, str):
|
||||
cmap = _get_cmap(colormap)
|
||||
elif normalized_colormap:
|
||||
cmap = ListedColormap(colormap)
|
||||
else:
|
||||
cmap = ListedColormap(np.array(colormap) / 255.0)
|
||||
return cmap
|
||||
|
||||
|
||||
def _check_color(color):
|
||||
from matplotlib.colors import colorConverter
|
||||
|
||||
if isinstance(color, str):
|
||||
color = colorConverter.to_rgb(color)
|
||||
elif isinstance(color, collections.abc.Iterable):
|
||||
np_color = np.array(color)
|
||||
if np_color.size % 3 != 0 and np_color.size % 4 != 0:
|
||||
raise ValueError("The expected valid format is RGB or RGBA.")
|
||||
if np_color.dtype in (np.int64, np.int32):
|
||||
if (np_color < 0).any() or (np_color > 255).any():
|
||||
raise ValueError("Values out of range [0, 255].")
|
||||
elif np_color.dtype == np.float64:
|
||||
if (np_color < 0.0).any() or (np_color > 1.0).any():
|
||||
raise ValueError("Values out of range [0.0, 1.0].")
|
||||
else:
|
||||
raise TypeError(
|
||||
"Expected data type is `np.int64`, `np.int32`, or `np.float64` but "
|
||||
f"{np_color.dtype} was given."
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected type is `str` or iterable but {type(color)} was given."
|
||||
)
|
||||
return color
|
||||
|
||||
|
||||
def _alpha_blend_background(ctable, background_color):
|
||||
alphas = ctable[:, -1][:, np.newaxis] / 255.0
|
||||
use_table = ctable.copy()
|
||||
use_table[:, -1] = 255.0
|
||||
return (use_table * alphas) + background_color * (1 - alphas)
|
||||
|
||||
|
||||
@functools.lru_cache(1)
|
||||
def _qt_init_icons():
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
QIcon.setThemeSearchPaths([str(_ICONS_PATH)] + QIcon.themeSearchPaths())
|
||||
QIcon.setFallbackThemeName("light")
|
||||
return str(_ICONS_PATH)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _qt_disable_paint(widget):
|
||||
paintEvent = widget.paintEvent
|
||||
widget.paintEvent = lambda *args, **kwargs: None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
widget.paintEvent = paintEvent
|
||||
|
||||
|
||||
_QT_ICON_KEYS = dict(app=None)
|
||||
|
||||
|
||||
def _init_mne_qtapp(enable_icon=True, pg_app=False, splash=False):
|
||||
"""Get QApplication-instance for MNE-Python.
|
||||
|
||||
Parameter
|
||||
---------
|
||||
enable_icon: bool
|
||||
If to set an MNE-icon for the app.
|
||||
pg_app: bool
|
||||
If to create the QApplication with pyqtgraph. For an until know
|
||||
undiscovered reason the pyqtgraph-browser won't show without
|
||||
mkQApp from pyqtgraph.
|
||||
splash : bool | str
|
||||
If not False, display a splash screen. If str, set the message
|
||||
to the given string.
|
||||
|
||||
Returns
|
||||
-------
|
||||
app : ``qtpy.QtWidgets.QApplication``
|
||||
Instance of QApplication.
|
||||
splash : ``qtpy.QtWidgets.QSplashScreen``
|
||||
Instance of QSplashScreen. Only returned if splash is True or a
|
||||
string.
|
||||
"""
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QGuiApplication, QIcon, QPixmap
|
||||
from qtpy.QtWidgets import QApplication, QSplashScreen
|
||||
|
||||
app_name = "MNE-Python"
|
||||
organization_name = "MNE"
|
||||
|
||||
# Fix from cbrnr/mnelab for app name in menu bar
|
||||
# This has to come *before* the creation of the QApplication to work.
|
||||
# It also only affects the title bar, not the application dock.
|
||||
# There seems to be no way to change the application dock from "python"
|
||||
# at runtime.
|
||||
if sys.platform.startswith("darwin"):
|
||||
try:
|
||||
# set bundle name on macOS (app name shown in the menu bar)
|
||||
from Foundation import NSBundle
|
||||
|
||||
bundle = NSBundle.mainBundle()
|
||||
info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
|
||||
if "CFBundleName" not in info:
|
||||
info["CFBundleName"] = app_name
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
# First we need to check to make sure the display is valid, otherwise
|
||||
# Qt might segfault on us
|
||||
app = QApplication.instance()
|
||||
if not (app or _display_is_valid()):
|
||||
raise RuntimeError("Cannot connect to a valid display")
|
||||
|
||||
if pg_app:
|
||||
from pyqtgraph import mkQApp
|
||||
|
||||
old_argv = sys.argv
|
||||
try:
|
||||
sys.argv = []
|
||||
app = mkQApp(app_name)
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
elif not app:
|
||||
app = QApplication([app_name])
|
||||
app.setApplicationName(app_name)
|
||||
app.setOrganizationName(organization_name)
|
||||
qt_version = _check_qt_version(check_usable_display=False)
|
||||
# HiDPI is enabled by default in Qt6, requires to be explicitly set for Qt5
|
||||
if _compare_version(qt_version, "<", "6.0"):
|
||||
app.setAttribute(Qt.AA_UseHighDpiPixmaps)
|
||||
|
||||
if enable_icon or splash:
|
||||
icons_path = _qt_init_icons()
|
||||
|
||||
if (
|
||||
enable_icon
|
||||
and app.windowIcon().cacheKey() != _QT_ICON_KEYS["app"]
|
||||
and app.windowIcon().isNull() # don't overwrite existing icon (e.g. MNELAB)
|
||||
):
|
||||
# Set icon
|
||||
kind = "bigsur_" if platform.mac_ver()[0] >= "10.16" else "default_"
|
||||
icon = QIcon(f"{icons_path}/mne_{kind}icon.png")
|
||||
app.setWindowIcon(icon)
|
||||
_QT_ICON_KEYS["app"] = app.windowIcon().cacheKey()
|
||||
|
||||
out = app
|
||||
if splash:
|
||||
pixmap = QPixmap(f"{icons_path}/mne_splash.png")
|
||||
pixmap.setDevicePixelRatio(QGuiApplication.primaryScreen().devicePixelRatio())
|
||||
args = (pixmap,)
|
||||
if _should_raise_window():
|
||||
args += (Qt.WindowStaysOnTopHint,)
|
||||
qsplash = QSplashScreen(*args)
|
||||
qsplash.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
if isinstance(splash, str):
|
||||
alignment = int(Qt.AlignBottom | Qt.AlignHCenter)
|
||||
qsplash.showMessage(splash, alignment=alignment, color=Qt.white)
|
||||
qsplash.show()
|
||||
app.processEvents()
|
||||
out = (out, qsplash)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _display_is_valid():
|
||||
# Adapted from matplotilb _c_internal_utils.py
|
||||
if sys.platform != "linux":
|
||||
return True
|
||||
if os.getenv("DISPLAY"): # if it's not there, don't bother
|
||||
libX11 = cdll.LoadLibrary("libX11.so.6")
|
||||
libX11.XOpenDisplay.restype = c_void_p
|
||||
libX11.XOpenDisplay.argtypes = [c_char_p]
|
||||
display = libX11.XOpenDisplay(None)
|
||||
if display is not None:
|
||||
libX11.XCloseDisplay.argtypes = [c_void_p]
|
||||
libX11.XCloseDisplay(display)
|
||||
return True
|
||||
# not found, try Wayland
|
||||
if os.getenv("WAYLAND_DISPLAY"):
|
||||
libwayland = cdll.LoadLibrary("libwayland-client.so.0")
|
||||
if libwayland is not None:
|
||||
if all(
|
||||
hasattr(libwayland, f"wl_display_{kind}connect") for kind in ("", "dis")
|
||||
):
|
||||
libwayland.wl_display_connect.restype = c_void_p
|
||||
libwayland.wl_display_connect.argtypes = [c_char_p]
|
||||
display = libwayland.wl_display_connect(None)
|
||||
if display:
|
||||
libwayland.wl_display_disconnect.argtypes = [c_void_p]
|
||||
libwayland.wl_display_disconnect(display)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# https://stackoverflow.com/questions/5160577/ctrl-c-doesnt-work-with-pyqt
|
||||
def _qt_app_exec(app):
|
||||
# adapted from matplotlib
|
||||
old_signal = signal.getsignal(signal.SIGINT)
|
||||
is_python_signal_handler = old_signal is not None
|
||||
if is_python_signal_handler:
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
try:
|
||||
# Make IPython Console accessible again in Spyder
|
||||
app.lastWindowClosed.connect(app.quit)
|
||||
app.exec_()
|
||||
finally:
|
||||
# reset the SIGINT exception handler
|
||||
if is_python_signal_handler:
|
||||
signal.signal(signal.SIGINT, old_signal)
|
||||
|
||||
|
||||
def _qt_detect_theme():
|
||||
try:
|
||||
import darkdetect
|
||||
|
||||
theme = darkdetect.theme().lower()
|
||||
except ModuleNotFoundError:
|
||||
logger.info(
|
||||
'For automatic theme detection, "darkdetect" has to'
|
||||
" be installed! You can install it with "
|
||||
"`pip install darkdetect`"
|
||||
)
|
||||
theme = "light"
|
||||
except Exception:
|
||||
theme = "light"
|
||||
return theme
|
||||
|
||||
|
||||
def _qt_get_stylesheet(theme):
|
||||
_validate_type(theme, ("path-like",), "theme")
|
||||
theme = str(theme)
|
||||
stylesheet = "" # no stylesheet
|
||||
if theme in ("auto", "dark", "light"):
|
||||
if theme == "auto":
|
||||
return stylesheet
|
||||
assert theme in ("dark", "light")
|
||||
system_theme = _qt_detect_theme()
|
||||
if theme == system_theme:
|
||||
return stylesheet
|
||||
_, api = _check_qt_version(return_api=True)
|
||||
# On macOS or Qt 6, we shouldn't need to set anything when the requested
|
||||
# theme matches that of the current OS state
|
||||
try:
|
||||
import qdarkstyle
|
||||
except ModuleNotFoundError:
|
||||
logger.info(
|
||||
f'To use {theme} mode when in {system_theme} mode, "qdarkstyle" has'
|
||||
"to be installed! You can install it with:\n"
|
||||
"pip install qdarkstyle\n"
|
||||
)
|
||||
else:
|
||||
if api in ("PySide6", "PyQt6") and _compare_version(
|
||||
qdarkstyle.__version__, "<", "3.2.3"
|
||||
):
|
||||
warn(
|
||||
f"Setting theme={repr(theme)} is not supported for {api} in "
|
||||
f"qdarkstyle {qdarkstyle.__version__}, it will be ignored. "
|
||||
"Consider upgrading qdarkstyle to >=3.2.3."
|
||||
)
|
||||
else:
|
||||
stylesheet = qdarkstyle.load_stylesheet(
|
||||
getattr(
|
||||
getattr(qdarkstyle, theme).palette,
|
||||
f"{theme.capitalize()}Palette",
|
||||
)
|
||||
)
|
||||
return stylesheet
|
||||
else:
|
||||
try:
|
||||
file = open(theme)
|
||||
except OSError:
|
||||
warn(
|
||||
"Requested theme file not found, will use light instead: "
|
||||
f"{repr(theme)}"
|
||||
)
|
||||
else:
|
||||
with file as fid:
|
||||
stylesheet = fid.read()
|
||||
return stylesheet
|
||||
|
||||
|
||||
def _should_raise_window():
|
||||
from matplotlib import rcParams
|
||||
|
||||
return rcParams["figure.raise_window"]
|
||||
|
||||
|
||||
def _qt_raise_window(widget):
|
||||
# Set raise_window like matplotlib if possible
|
||||
if _should_raise_window():
|
||||
widget.activateWindow()
|
||||
widget.raise_()
|
||||
|
||||
|
||||
def _qt_is_dark(widget):
|
||||
# Ideally this would use CIELab, but this should be good enough
|
||||
win = widget.window()
|
||||
bgcolor = win.palette().color(win.backgroundRole()).getRgbF()[:3]
|
||||
return rgb_to_hls(*bgcolor)[1] < 0.5
|
||||
|
||||
|
||||
def _pixmap_to_ndarray(pixmap):
|
||||
from qtpy.QtGui import QImage
|
||||
|
||||
img = pixmap.toImage()
|
||||
img = img.convertToFormat(QImage.Format.Format_RGBA8888)
|
||||
ptr = img.bits()
|
||||
count = img.height() * img.width() * 4
|
||||
if hasattr(ptr, "setsize"): # PyQt
|
||||
ptr.setsize(count)
|
||||
data = np.frombuffer(ptr, dtype=np.uint8, count=count).copy()
|
||||
data.shape = (img.height(), img.width(), 4)
|
||||
return data / 255.0
|
||||
|
||||
|
||||
def _notebook_vtk_works():
|
||||
if sys.platform != "linux":
|
||||
return True
|
||||
# check if it's OSMesa -- if it is, continue
|
||||
try:
|
||||
from vtkmodules import vtkRenderingOpenGL2
|
||||
|
||||
vtkRenderingOpenGL2.vtkOSOpenGLRenderWindow
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
return True # has vtkOSOpenGLRenderWindow (OSMesa build)
|
||||
|
||||
# if it's not OSMesa, we need to check display validity
|
||||
if _display_is_valid():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _qt_safe_window(
|
||||
*, splash="figure.splash", window="figure.plotter.app_window", always_close=True
|
||||
):
|
||||
def dec(meth, splash=splash, always_close=always_close):
|
||||
@functools.wraps(meth)
|
||||
def func(self, *args, **kwargs):
|
||||
close_splash = always_close
|
||||
error = False
|
||||
try:
|
||||
meth(self, *args, **kwargs)
|
||||
except Exception:
|
||||
close_splash = error = True
|
||||
raise
|
||||
finally:
|
||||
for attr, do_close in ((splash, close_splash), (window, error)):
|
||||
if attr is None or not do_close:
|
||||
continue
|
||||
parent = self
|
||||
name = attr.split(".")[-1]
|
||||
try:
|
||||
for n in attr.split(".")[:-1]:
|
||||
parent = getattr(parent, n)
|
||||
if name:
|
||||
widget = getattr(parent, name, False)
|
||||
else: # empty string means "self"
|
||||
widget = parent
|
||||
if widget:
|
||||
widget.close()
|
||||
del widget
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
delattr(parent, name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return func
|
||||
|
||||
return dec
|
||||
587
mne/viz/backends/renderer.py
Normal file
587
mne/viz/backends/renderer.py
Normal file
@@ -0,0 +1,587 @@
|
||||
"""Core visualization operations."""
|
||||
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import importlib
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...utils import (
|
||||
_auto_weakref,
|
||||
_check_option,
|
||||
_validate_type,
|
||||
fill_doc,
|
||||
get_config,
|
||||
logger,
|
||||
verbose,
|
||||
)
|
||||
from .._3d import _get_3d_option
|
||||
from ..utils import safe_event
|
||||
from ._utils import VALID_3D_BACKENDS
|
||||
|
||||
MNE_3D_BACKEND = None
|
||||
MNE_3D_BACKEND_TESTING = False
|
||||
|
||||
|
||||
_backend_name_map = dict(
|
||||
pyvistaqt="._qt",
|
||||
notebook="._notebook",
|
||||
)
|
||||
backend = None
|
||||
|
||||
|
||||
def _reload_backend(backend_name):
|
||||
global backend
|
||||
backend = importlib.import_module(
|
||||
name=_backend_name_map[backend_name], package="mne.viz.backends"
|
||||
)
|
||||
logger.info(f"Using {backend_name} 3d backend.")
|
||||
|
||||
|
||||
def _get_backend():
|
||||
_get_3d_backend()
|
||||
return backend
|
||||
|
||||
|
||||
def _get_renderer(*args, **kwargs):
|
||||
_get_3d_backend()
|
||||
return backend._Renderer(*args, **kwargs)
|
||||
|
||||
|
||||
def _check_3d_backend_name(backend_name):
|
||||
_validate_type(backend_name, str, "backend_name")
|
||||
backend_name = "pyvistaqt" if backend_name == "pyvista" else backend_name
|
||||
_check_option("backend_name", backend_name, VALID_3D_BACKENDS)
|
||||
return backend_name
|
||||
|
||||
|
||||
@verbose
|
||||
def set_3d_backend(backend_name, verbose=None):
|
||||
"""Set the 3D backend for MNE.
|
||||
|
||||
The backend will be set as specified and operations will use
|
||||
that backend.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend_name : str
|
||||
The 3d backend to select. See Notes for the capabilities of each
|
||||
backend (``'pyvistaqt'`` and ``'notebook'``).
|
||||
|
||||
.. versionchanged:: 0.24
|
||||
The ``'pyvista'`` backend was renamed ``'pyvistaqt'``.
|
||||
%(verbose)s
|
||||
|
||||
Returns
|
||||
-------
|
||||
old_backend_name : str | None
|
||||
The old backend that was in use.
|
||||
|
||||
Notes
|
||||
-----
|
||||
To use PyVista, set ``backend_name`` to ``pyvistaqt`` but the value
|
||||
``pyvista`` is still supported for backward compatibility.
|
||||
|
||||
This table shows the capabilities of each backend ("✓" for full support,
|
||||
and "-" for partial support):
|
||||
|
||||
.. table::
|
||||
:widths: auto
|
||||
|
||||
+--------------------------------------+-----------+----------+
|
||||
| **3D function:** | pyvistaqt | notebook |
|
||||
+======================================+===========+==========+
|
||||
| :func:`plot_vector_source_estimates` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`plot_source_estimates` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`plot_alignment` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`plot_sparse_source_estimates` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`plot_evoked_field` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`snapshot_brain_montage` | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| :func:`link_brains` | ✓ | |
|
||||
+--------------------------------------+-----------+----------+
|
||||
+--------------------------------------+-----------+----------+
|
||||
| **Feature:** |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Large data | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Opacity/transparency | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Support geometric glyph | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Smooth shading | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Subplotting | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Inline plot in Jupyter Notebook | | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Inline plot in JupyterLab | | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Inline plot in Google Colab | | |
|
||||
+--------------------------------------+-----------+----------+
|
||||
| Toolbar | ✓ | ✓ |
|
||||
+--------------------------------------+-----------+----------+
|
||||
"""
|
||||
global MNE_3D_BACKEND
|
||||
old_backend_name = MNE_3D_BACKEND
|
||||
backend_name = _check_3d_backend_name(backend_name)
|
||||
if MNE_3D_BACKEND != backend_name:
|
||||
_reload_backend(backend_name)
|
||||
MNE_3D_BACKEND = backend_name
|
||||
return old_backend_name
|
||||
|
||||
|
||||
def get_3d_backend():
|
||||
"""Return the 3D backend currently used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
backend_used : str | None
|
||||
The 3d backend currently in use. If no backend is found,
|
||||
returns ``None``.
|
||||
|
||||
.. versionchanged:: 0.24
|
||||
The ``'pyvista'`` backend has been renamed ``'pyvistaqt'``, so
|
||||
``'pyvista'`` is no longer returned by this function.
|
||||
"""
|
||||
try:
|
||||
backend = _get_3d_backend()
|
||||
except RuntimeError as exc:
|
||||
backend = None
|
||||
logger.info(str(exc))
|
||||
return backend
|
||||
|
||||
|
||||
def _get_3d_backend():
|
||||
"""Load and return the current 3d backend."""
|
||||
global MNE_3D_BACKEND
|
||||
if MNE_3D_BACKEND is None:
|
||||
MNE_3D_BACKEND = get_config(key="MNE_3D_BACKEND", default=None)
|
||||
if MNE_3D_BACKEND is None: # try them in order
|
||||
errors = dict()
|
||||
for name in VALID_3D_BACKENDS:
|
||||
try:
|
||||
_reload_backend(name)
|
||||
except ImportError as exc:
|
||||
errors[name] = str(exc)
|
||||
else:
|
||||
MNE_3D_BACKEND = name
|
||||
break
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Could not load any valid 3D backend\n"
|
||||
+ "\n".join(f"{key}: {val}" for key, val in errors.items())
|
||||
+ "\n".join(
|
||||
(
|
||||
"\n\n install pyvistaqt, using pip or conda:",
|
||||
"'pip install pyvistaqt'",
|
||||
"'conda install -c conda-forge pyvistaqt'",
|
||||
"\n or install ipywidgets, "
|
||||
+ "if using a notebook backend",
|
||||
"'pip install ipywidgets'",
|
||||
"'conda install -c conda-forge ipywidgets'",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
MNE_3D_BACKEND = _check_3d_backend_name(MNE_3D_BACKEND)
|
||||
_reload_backend(MNE_3D_BACKEND)
|
||||
MNE_3D_BACKEND = _check_3d_backend_name(MNE_3D_BACKEND)
|
||||
return MNE_3D_BACKEND
|
||||
|
||||
|
||||
@contextmanager
|
||||
def use_3d_backend(backend_name):
|
||||
"""Create a 3d visualization context using the designated backend.
|
||||
|
||||
See :func:`mne.viz.set_3d_backend` for more details on the available
|
||||
3d backends and their capabilities.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend_name : {'pyvistaqt', 'notebook'}
|
||||
The 3d backend to use in the context.
|
||||
"""
|
||||
old_backend = set_3d_backend(backend_name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if old_backend is not None:
|
||||
try:
|
||||
set_3d_backend(old_backend)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _use_test_3d_backend(backend_name, interactive=False):
|
||||
"""Create a testing viz context.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend_name : str
|
||||
The 3d backend to use in the context.
|
||||
interactive : bool
|
||||
If True, ensure interactive elements are accessible.
|
||||
"""
|
||||
with _actors_invisible():
|
||||
with use_3d_backend(backend_name):
|
||||
with backend._testing_context(interactive):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _actors_invisible():
|
||||
global MNE_3D_BACKEND_TESTING
|
||||
orig_testing = MNE_3D_BACKEND_TESTING
|
||||
MNE_3D_BACKEND_TESTING = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
MNE_3D_BACKEND_TESTING = orig_testing
|
||||
|
||||
|
||||
@fill_doc
|
||||
def set_3d_view(
|
||||
figure,
|
||||
azimuth=None,
|
||||
elevation=None,
|
||||
focalpoint=None,
|
||||
distance=None,
|
||||
roll=None,
|
||||
):
|
||||
"""Configure the view of the given scene.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
figure : object
|
||||
The scene which is modified.
|
||||
%(azimuth)s
|
||||
%(elevation)s
|
||||
%(focalpoint)s
|
||||
%(distance)s
|
||||
%(roll)s
|
||||
"""
|
||||
backend._set_3d_view(
|
||||
figure=figure,
|
||||
azimuth=azimuth,
|
||||
elevation=elevation,
|
||||
focalpoint=focalpoint,
|
||||
distance=distance,
|
||||
roll=roll,
|
||||
)
|
||||
|
||||
|
||||
@fill_doc
|
||||
def set_3d_title(figure, title, size=40, *, color="white", position="upper_left"):
|
||||
"""Configure the title of the given scene.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
figure : object
|
||||
The scene which is modified.
|
||||
title : str
|
||||
The title of the scene.
|
||||
size : int
|
||||
The size of the title.
|
||||
color : matplotlib color
|
||||
The color of the title.
|
||||
|
||||
.. versionadded:: 1.9
|
||||
position : str
|
||||
The position to use, e.g., "upper_left". See
|
||||
:meth:`pyvista.Plotter.add_text` for details.
|
||||
|
||||
.. versionadded:: 1.9
|
||||
|
||||
Returns
|
||||
-------
|
||||
text : object
|
||||
The text object returned by the given backend.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
"""
|
||||
return backend._set_3d_title(
|
||||
figure=figure, title=title, size=size, color=color, position=position
|
||||
)
|
||||
|
||||
|
||||
def create_3d_figure(
|
||||
size,
|
||||
bgcolor=(0, 0, 0),
|
||||
smooth_shading=None,
|
||||
handle=None,
|
||||
*,
|
||||
scene=True,
|
||||
show=False,
|
||||
title="MNE 3D Figure",
|
||||
):
|
||||
"""Return an empty figure based on the current 3d backend.
|
||||
|
||||
.. warning:: Proceed with caution when the renderer object is
|
||||
returned (with ``scene=False``) because the _Renderer
|
||||
API is not necessarily stable enough for production,
|
||||
it's still actively in development.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
size : tuple
|
||||
The dimensions of the 3d figure (width, height).
|
||||
bgcolor : tuple
|
||||
The color of the background.
|
||||
smooth_shading : bool | None
|
||||
Whether to enable smooth shading. If ``None``, uses the config value
|
||||
``MNE_3D_OPTION_SMOOTH_SHADING``. Defaults to ``None``.
|
||||
handle : int | None
|
||||
The figure identifier.
|
||||
scene : bool
|
||||
If True (default), the returned object is the Figure3D. If False,
|
||||
an advanced, undocumented Renderer object is returned (the API is not
|
||||
stable or documented, so this is not recommended).
|
||||
show : bool
|
||||
If True, show the renderer immediately.
|
||||
|
||||
.. versionadded:: 1.0
|
||||
title : str
|
||||
The window title to use (if applicable).
|
||||
|
||||
.. versionadded:: 1.9
|
||||
|
||||
Returns
|
||||
-------
|
||||
figure : instance of Figure3D or ``Renderer``
|
||||
The requested empty figure or renderer, depending on ``scene``.
|
||||
"""
|
||||
_validate_type(smooth_shading, (bool, None), "smooth_shading")
|
||||
if smooth_shading is None:
|
||||
smooth_shading = _get_3d_option("smooth_shading")
|
||||
renderer = _get_renderer(
|
||||
fig=handle,
|
||||
size=size,
|
||||
bgcolor=bgcolor,
|
||||
smooth_shading=smooth_shading,
|
||||
show=show,
|
||||
name=title,
|
||||
)
|
||||
if scene:
|
||||
return renderer.scene()
|
||||
else:
|
||||
return renderer
|
||||
|
||||
|
||||
def close_3d_figure(figure):
|
||||
"""Close the given scene.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
figure : object
|
||||
The scene which needs to be closed.
|
||||
"""
|
||||
backend._close_3d_figure(figure)
|
||||
|
||||
|
||||
def close_all_3d_figures():
|
||||
"""Close all the scenes of the current 3d backend."""
|
||||
backend._close_all()
|
||||
|
||||
|
||||
def get_brain_class():
|
||||
"""Return the proper Brain class based on the current 3d backend.
|
||||
|
||||
Returns
|
||||
-------
|
||||
brain : object
|
||||
The Brain class corresponding to the current 3d backend.
|
||||
"""
|
||||
from ...viz._brain import Brain
|
||||
|
||||
return Brain
|
||||
|
||||
|
||||
class _TimeInteraction:
|
||||
"""Mixin enabling time interaction controls."""
|
||||
|
||||
def _enable_time_interaction(
|
||||
self,
|
||||
fig,
|
||||
current_time_func,
|
||||
times,
|
||||
init_playback_speed=0.01,
|
||||
playback_speed_range=(0.01, 0.1),
|
||||
):
|
||||
from ..ui_events import (
|
||||
PlaybackSpeed,
|
||||
TimeChange,
|
||||
publish,
|
||||
subscribe,
|
||||
)
|
||||
|
||||
self._fig = fig
|
||||
self._current_time_func = current_time_func
|
||||
self._times = times
|
||||
self._init_time = current_time_func()
|
||||
self._init_playback_speed = init_playback_speed
|
||||
|
||||
if not hasattr(self, "_dock"):
|
||||
self._dock_initialize()
|
||||
|
||||
if not hasattr(self, "_tool_bar") or self._tool_bar is None:
|
||||
self._tool_bar_initialize(name="Toolbar")
|
||||
|
||||
if not hasattr(self, "_widgets"):
|
||||
self._widgets = dict()
|
||||
|
||||
# Dock widgets
|
||||
@_auto_weakref
|
||||
def publish_time_change(time_index):
|
||||
publish(
|
||||
fig,
|
||||
TimeChange(time=np.interp(time_index, np.arange(len(times)), times)),
|
||||
)
|
||||
|
||||
layout = self._dock_add_group_box("")
|
||||
self._widgets["time_slider"] = self._dock_add_slider(
|
||||
name="Time (s)",
|
||||
value=np.interp(current_time_func(), times, np.arange(len(times))),
|
||||
rng=[0, len(times) - 1],
|
||||
double=True,
|
||||
callback=publish_time_change,
|
||||
compact=False,
|
||||
layout=layout,
|
||||
)
|
||||
hlayout = self._dock_add_layout(vertical=False)
|
||||
self._widgets["min_time"] = self._dock_add_label("-", layout=hlayout)
|
||||
self._dock_add_stretch(hlayout)
|
||||
self._widgets["current_time"] = self._dock_add_label(value="x", layout=hlayout)
|
||||
self._dock_add_stretch(hlayout)
|
||||
self._widgets["max_time"] = self._dock_add_label(value="+", layout=hlayout)
|
||||
self._layout_add_widget(layout, hlayout)
|
||||
|
||||
self._widgets["min_time"].set_value(f"{times[0]: .3f}")
|
||||
self._widgets["current_time"].set_value(f"{current_time_func(): .3f}")
|
||||
self._widgets["max_time"].set_value(f"{times[-1]: .3f}")
|
||||
|
||||
@_auto_weakref
|
||||
def publish_playback_speed(speed):
|
||||
publish(fig, PlaybackSpeed(speed=speed))
|
||||
|
||||
self._widgets["playback_speed"] = self._dock_add_spin_box(
|
||||
name="Speed",
|
||||
value=init_playback_speed,
|
||||
rng=playback_speed_range,
|
||||
callback=publish_playback_speed,
|
||||
layout=layout,
|
||||
)
|
||||
|
||||
# Tool bar buttons
|
||||
self._widgets["reset"] = self._tool_bar_add_button(
|
||||
name="reset", desc="Reset", func=self._reset_time
|
||||
)
|
||||
self._widgets["play"] = self._tool_bar_add_play_button(
|
||||
name="play",
|
||||
desc="Play/Pause",
|
||||
func=self._toggle_playback,
|
||||
shortcut=" ",
|
||||
)
|
||||
|
||||
# Configure playback
|
||||
self._playback = False
|
||||
self._playback_initialize(
|
||||
func=self._play,
|
||||
timeout=17,
|
||||
value=np.interp(current_time_func(), times, np.arange(len(times))),
|
||||
rng=[0, len(times) - 1],
|
||||
time_widget=self._widgets["time_slider"],
|
||||
play_widget=self._widgets["play"],
|
||||
)
|
||||
|
||||
# Keyboard shortcuts
|
||||
@_auto_weakref
|
||||
def shift_time(direction):
|
||||
amount = self._widgets["playback_speed"].get_value()
|
||||
publish(
|
||||
self._fig,
|
||||
TimeChange(time=self._current_time_func() + direction * amount),
|
||||
)
|
||||
|
||||
if self.plotter.iren is not None:
|
||||
self.plotter.add_key_event("n", partial(shift_time, direction=1))
|
||||
self.plotter.add_key_event("b", partial(shift_time, direction=-1))
|
||||
|
||||
# Subscribe to relevant UI events
|
||||
subscribe(fig, "time_change", self._on_time_change)
|
||||
subscribe(fig, "playback_speed", self._on_playback_speed)
|
||||
|
||||
def _on_time_change(self, event):
|
||||
"""Respond to time_change UI event."""
|
||||
from ..ui_events import disable_ui_events
|
||||
|
||||
new_time = np.clip(event.time, self._times[0], self._times[-1])
|
||||
new_time_idx = np.interp(new_time, self._times, np.arange(len(self._times)))
|
||||
|
||||
with disable_ui_events(self._fig):
|
||||
self._widgets["time_slider"].set_value(new_time_idx)
|
||||
self._widgets["current_time"].set_value(f"{new_time:.3f}")
|
||||
|
||||
def _on_playback_speed(self, event):
|
||||
"""Respond to playback_speed UI event."""
|
||||
from ..ui_events import disable_ui_events
|
||||
|
||||
with disable_ui_events(self._fig):
|
||||
self._widgets["playback_speed"].set_value(event.speed)
|
||||
|
||||
def _toggle_playback(self, value=None):
|
||||
"""Toggle time playback."""
|
||||
from ..ui_events import TimeChange, publish
|
||||
|
||||
if value is None:
|
||||
self._playback = not self._playback
|
||||
else:
|
||||
self._playback = value
|
||||
|
||||
if self._playback:
|
||||
self._tool_bar_update_button_icon(name="play", icon_name="pause")
|
||||
if self._current_time_func() == self._times[-1]: # start over
|
||||
publish(self._fig, TimeChange(time=self._times[0]))
|
||||
self._last_tick = time.time()
|
||||
else:
|
||||
self._tool_bar_update_button_icon(name="play", icon_name="play")
|
||||
|
||||
def _reset_time(self):
|
||||
"""Reset time and playback speed to initial values."""
|
||||
from ..ui_events import PlaybackSpeed, TimeChange, publish
|
||||
|
||||
publish(self._fig, TimeChange(time=self._init_time))
|
||||
publish(self._fig, PlaybackSpeed(speed=self._init_playback_speed))
|
||||
|
||||
@safe_event
|
||||
def _play(self):
|
||||
if self._playback:
|
||||
try:
|
||||
self._advance()
|
||||
except Exception:
|
||||
self._toggle_playback(value=False)
|
||||
raise
|
||||
|
||||
def _advance(self):
|
||||
from ..ui_events import TimeChange, publish
|
||||
|
||||
this_time = time.time()
|
||||
delta = this_time - self._last_tick
|
||||
self._last_tick = time.time()
|
||||
time_shift = delta * self._widgets["playback_speed"].get_value()
|
||||
new_time = min(self._current_time_func() + time_shift, self._times[-1])
|
||||
publish(self._fig, TimeChange(time=new_time))
|
||||
if new_time == self._times[-1]:
|
||||
self._toggle_playback(value=False)
|
||||
Reference in New Issue
Block a user