diff --git a/doc/changes/dev/14150.other.rst b/doc/changes/dev/14150.other.rst new file mode 100644 index 00000000000..948bb812fb1 --- /dev/null +++ b/doc/changes/dev/14150.other.rst @@ -0,0 +1 @@ +Add the setup cell that installs MNE into the browser kernel for the JupyterLite documentation, by `Natneal B`_. diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py new file mode 100644 index 00000000000..32d5daa2219 --- /dev/null +++ b/doc/sphinxext/_lite_setup_cell.py @@ -0,0 +1,460 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# Notebook source, not a module: it installs packages with a top-level ``await`` +# and imports them afterwards, so import placement rules do not apply. Keeping +# it a real file is what lets ruff lint and format the rest of it. +# ruff: noqa: E402, F704, I001 +# +# Everything defined here lands in the notebook's namespace, so names are +# _-prefixed to stay out of the tutorials' way; ``mne_data_path`` is the one +# deliberate exception, since a reader may want it. + +# --- JupyterLite setup cell ------------------------------------------------- +# 💡 This cell is added to the start of every notebook. It installs MNE and +# patches the browser environment for Pyodide. Running this notebook locally? +# Delete this cell first: piplite only exists inside JupyterLite. + +# === 1. Install ============================================================== +import piplite + +# piplite (not micropip) prefers the development MNE wheel bundled with the docs +# over PyPI; keep_going reports a dependency with no wheel instead of aborting +await piplite.install( + [ + "mne", + "scikit-learn", + "joblib", + "pandas", + "seaborn", + "mne-connectivity", + "nibabel", + "pyvista-js", + "pyxdf", + "mffpy", + "python-picard", + ], + keep_going=True, +) + +# === 2. Pyodide compatibility, before MNE is imported ======================== +import sys +import os +import inspect +import io +from pathlib import Path + +# Route requests through the browser, for the pooch downloads that fetch from +# off-site (fetch_fsaverage and friends). A synchronous XMLHttpRequest reports +# the real HTTP status, which is what pooch's raise_for_status() needs. +import requests + + +def _pyodide_send(self, request, **kwargs): + from js import XMLHttpRequest + + _xhr = XMLHttpRequest.new() + _xhr.open(request.method or "GET", request.url, False) + _xhr.responseType = "arraybuffer" + _xhr.send() + response = requests.Response() + response.status_code = _xhr.status + response.reason = _xhr.statusText + response.url = request.url + response.raw = io.BytesIO(bytes(_xhr.response.to_py())) + return response + + +requests.Session.send = _pyodide_send + +# === 3. Where the data comes from =========================================== +# The docs serve the data next to the pages (/mne_data/, via html_extra_path), +# and every file is fetched into the virtual filesystem on first use by the +# wrappers below. Pyodide may run in a web worker, where ``location`` exists +# but ``window`` does not. +import js + +_base = str(js.location.href).split("/lite/")[0] + "/mne_data/" +mne_data_path = "/tmp/mne_data" +_mne_data_root = Path(mne_data_path) +_mne_data_root.mkdir(parents=True, exist_ok=True) +os.environ["MNE_DATA"] = mne_data_path + +# an OSF download would fail on CORS or memory; say so instead +import pooch +from urllib.parse import urlparse + +_orig_pooch_fetch = pooch.Pooch.fetch + + +def _pyodide_pooch_fetch(self, fname, processor=None, downloader=None): + host = urlparse(self.get_url(fname)).hostname or "" + if host == "osf.io" or host.endswith(".osf.io"): + raise RuntimeError( + f"Cannot download {fname!r} from OSF in JupyterLite: browser CORS " + "policy and memory limits prevent large dataset downloads. Open this " + "notebook from mne.tools, where the data is bundled, or run it locally." + ) + return _orig_pooch_fetch(self, fname, processor=processor, downloader=downloader) + + +pooch.Pooch.fetch = _pyodide_pooch_fetch + +# === 4. Fetch helpers ======================================================= +import mne + + +def _lite_rel_to_data(fname): + """Return ``fname`` relative to the data root, or None if it sits outside.""" + _p = Path(str(fname)) + if _p == _mne_data_root or not _p.is_relative_to(_mne_data_root): + return None + return _p.relative_to(_mne_data_root).as_posix() + + +def _lite_fetch_rel(rel): + """Download one file (once) into the virtual filesystem and return its path. + + Synchronous, since it runs inside MNE readers, which cannot await; a + blocking XHR may read binary in a web worker, where the kernel runs. + """ + _dst = _mne_data_root / rel + if not _dst.exists(): + from js import XMLHttpRequest + + _xhr = XMLHttpRequest.new() + _xhr.open("GET", _base + rel, False) + _xhr.responseType = "arraybuffer" + _xhr.send() + if _xhr.status != 200: + raise FileNotFoundError(f"Could not fetch {rel} (HTTP {_xhr.status})") + _dst.parent.mkdir(parents=True, exist_ok=True) + _dst.write_bytes(bytes(_xhr.response.to_py())) + return _dst + + +def _lite_fetch_if_under_mne_data(fname): + """Fetch ``fname`` if we serve it, and hand it back either way.""" + _rel = _lite_rel_to_data(fname) + if _rel is not None: + _lite_fetch_rel(_rel) + return fname + + +def _lite_fetch_optional(rels): + """Fetch what is served among ``rels``, quietly skipping the rest. + + These are candidates MNE will choose between, or optional companions of a + multi-file format; the reader raises its own error for a file it needed. + """ + for _r in rels: + try: + _lite_fetch_rel(_r) + except Exception: + pass + + +def _lite_fetch_candidates(subject, subjects_dir, rel_paths): + """Fetch ``rel_paths`` under ``//``.""" + _rel = _lite_rel_to_data(subjects_dir if subjects_dir is not None else "") + if subject and _rel is not None: + _lite_fetch_optional(f"{_rel}/{subject}/{_p}" for _p in rel_paths) + + +def _lite_wrap_reader(module, name, siblings=None): + """Wrap ``module.name`` to fetch its filename argument before it opens it. + + ``siblings`` maps a relative path to the other files that name implies (a + BrainVision header's .eeg and .vmrk). The filename's keyword is read off + the signature, since readers call it fname, filename or input_fname. + """ + orig = getattr(module, name) + arg = next(iter(inspect.signature(orig).parameters)) + + def wrapped(*args, **kwargs): + if arg in kwargs: # normalize to positional + args = (kwargs.pop(arg),) + args + if args: + _rel = _lite_rel_to_data(args[0]) + if _rel is not None: + _lite_fetch_optional([_rel] + (siblings(_rel) if siblings else [])) + return orig(*args, **kwargs) + + setattr(module, name, wrapped) + _lite_rebind(name, orig, wrapped) # modules that imported it by name + + +def _lite_dir_reader(orig): + """Wrap a reader of a folder, listed by the manifest conf.py leaves in it.""" + + def _read(fname, *args, **kwargs): + _rel = _lite_rel_to_data(fname) + if _rel is not None: + try: + _names = _lite_fetch_rel(_rel + "/_lite_manifest.txt").read_text() + _lite_fetch_optional(_rel + "/" + _n for _n in _names.split()) + except Exception as _e: + print("[JupyterLite] could not fetch " + str(fname) + ": " + repr(_e)) + return orig(fname, *args, **kwargs) + + return _read + + +def _lite_rebind(name, old, new): + """Point every MNE module that already imported ``old`` at ``new``.""" + for _m in list(sys.modules.values()): + if ( + getattr(_m, "__name__", "").startswith("mne") + and getattr(_m, name, None) is old + ): + setattr(_m, name, new) + + +# === 5. Where MNE looks for each dataset ==================================== +# data_path() would download the archive from OSF; point it at the served +# folder instead. A probe file is fetched for datasets whose data is read by +# something other than an MNE reader (scipy for mtrf). +def _lite_dataset_path(folder, probe=None): + def _data_path(*args, **kwargs): + if probe is not None: + _lite_fetch_rel(folder + "/" + probe) + return _mne_data_root / folder + + return _data_path + + +for _ds, _folder, _probe in ( + ("sample", "MNE-sample-data", None), + ("testing", "MNE-testing-data", None), + ("ssvep", "ssvep-example-data", None), + ("misc", "MNE-misc-data", None), + ("eyelink", "MNE-eyelink-data", None), + ("fnirs_motor", "MNE-fNIRS-motor-data", None), + ("refmeg_noise", "MNE-refmeg-noise-data", None), + ("phantom_kernel", "MNE-phantom-kernel-data", None), + ("multimodal", "MNE-multimodal-data", None), + ("kiloword", "MNE-kiloword-data", "kword_metadata-epo.fif"), + ("erp_core", "MNE-ERP-CORE-data", "ERP-CORE_Subject-001_Task-Flankers_eeg.fif"), + ("mtrf", "mTRF_1.5", "speech_data.mat"), +): + getattr(mne.datasets, _ds).data_path = _lite_dataset_path(_folder, _probe) +del _ds, _folder, _probe + + +def _lite_eegbci_load_data(subjects, runs, *args, **kwargs): # by subject and run + _runs = [runs] if isinstance(runs, (int, float)) else list(runs) + _subjects = list(subjects) if isinstance(subjects, (list, tuple)) else [subjects] + return [ + _lite_fetch_rel( + f"MNE-eegbci-data/files/eegmmidb/1.0.0/S{_s:03d}/S{_s:03d}R{_r:02d}.edf" + ) + for _s in _subjects + for _r in _runs + ] + + +mne.datasets.eegbci.load_data = _lite_eegbci_load_data + +# === 6. Readers ============================================================= +# Nearly every reader validates its filename with _check_fname(must_exist=True) +# before opening it, so one hook there fetches for all of them. The rest need +# one of three things: their own wrapper because they skip that check, the +# other files a single name implies, or a fetch before a filesystem probe +# (os.path.exists, glob) that no reader would ever trigger. +import mne.utils.check as mne_check + +_orig_check_fname = mne_check._check_fname + + +def _lite_check_fname( + fname, overwrite=False, must_exist=False, name="File", need_dir=False, **kwargs +): + _rel = _lite_rel_to_data(fname) if must_exist else None + if _rel is not None and need_dir: # a served folder's files arrive on demand + (_mne_data_root / _rel).mkdir(parents=True, exist_ok=True) + elif _rel is not None: + try: + _lite_fetch_rel(_rel) + except Exception: + pass # let MNE raise its own error for a missing file + return _orig_check_fname(fname, overwrite, must_exist, name, need_dir, **kwargs) + + +mne_check._check_fname = _lite_check_fname +_lite_rebind("_check_fname", _orig_check_fname, _lite_check_fname) + +import matplotlib.pyplot as plt # imread: the eyetracking heatmap's stimulus + +for _module, _name, _siblings in ( + (plt, "imread", None), + (mne.io, "read_raw_eeglab", lambda rel: [rel.removesuffix(".set") + ".fdt"]), + ( + mne.io, + "read_raw_brainvision", + lambda rel: [rel.removesuffix(".vhdr") + s for s in (".eeg", ".vmrk")], + ), + ( + mne, + "read_source_estimate", + lambda rel: [rel + s for s in ("-lh.stc", "-rh.stc")], + ), +): + _lite_wrap_reader(_module, _name, _siblings) +del _module, _name, _siblings +try: # pyxdf has no wheel on every Pyodide build; only the XDF example needs it + import pyxdf + + _lite_wrap_reader(pyxdf, "load_xdf") +except Exception: + pass +# folders rather than files +mne.io.read_raw_nirx = _lite_dir_reader(mne.io.read_raw_nirx) +mne.io.read_raw_egi = _lite_dir_reader(mne.io.read_raw_egi) + +# Filesystem probes: fetch the candidates first, in the order MNE tries them, +# then let it choose as it normally would. The viz modules bind these names at +# import, hence the rebinds. +import mne._freesurfer as mne_fs +import mne.surface as mne_surface +import mne.viz._3d # noqa: F401 + +_orig_get_head_surface = mne_fs._get_head_surface +_orig_get_skull_surface = mne_fs._get_skull_surface +_orig_surface_head = mne_surface._get_head_surface +_orig_plot_bem = mne.viz.plot_bem + + +def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): + if surf in ("head-dense", "seghead"): + _cands = [f"bem/{subject}-head-dense.fif", "surf/lh.seghead"] + else: + _cands = ["bem/outer_skin.surf", f"bem/{subject}-head.fif"] + _lite_fetch_candidates(subject, subjects_dir, _cands) + return _orig_get_head_surface(surf, subject, subjects_dir, bem=bem, verbose=verbose) + + +def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): + _lite_fetch_candidates(subject, subjects_dir, [f"bem/{surf}_skull.surf"]) + return _orig_get_skull_surface( + surf, subject, subjects_dir, bem=bem, verbose=verbose + ) + + +def _lite_surface_head_surface( + subject, source, subjects_dir, on_defects, raise_error=True +): + _srcs = [source] if isinstance(source, str) else list(source) + _lite_fetch_candidates( + subject, subjects_dir, [f"bem/{subject}-{_s}.fif" for _s in _srcs] + ) + return _orig_surface_head( + subject, source, subjects_dir, on_defects, raise_error=raise_error + ) + + +def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): + _want = ["bem/inner_skull.surf", "bem/outer_skull.surf", "bem/outer_skin.surf"] + _want.append("mri/" + str(kwargs.get("mri", "T1.mgz"))) + _bs = kwargs.get("brain_surfaces") + for _b in [_bs] if isinstance(_bs, str) else _bs or []: + _want += [f"surf/lh.{_b}", f"surf/rh.{_b}"] + _lite_fetch_candidates(subject, subjects_dir, _want) + return _orig_plot_bem(subject, subjects_dir, *args, **kwargs) + + +mne_fs._get_head_surface = _lite_get_head_surface +_lite_rebind("_get_head_surface", _orig_get_head_surface, _lite_get_head_surface) +mne_fs._get_skull_surface = _lite_get_skull_surface +_lite_rebind("_get_skull_surface", _orig_get_skull_surface, _lite_get_skull_surface) +mne_surface._get_head_surface = _lite_surface_head_surface +mne.viz.plot_bem = _lite_plot_bem + +# The logging tutorial reads a KIT file from inside the installed package, +# which the wheel leaves out, so stage the served copy where it looks. +import shutil + +_orig_read_raw_kit = mne.io.read_raw_kit + + +def _lite_read_raw_kit(input_fname, *args, **kwargs): + _p = Path(str(input_fname)) + if _p.name == "test.sqd" and not _p.exists(): + try: + _p.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(_lite_fetch_rel("MNE-kit-testdata/test.sqd"), _p) + except Exception as _e: + print("[JupyterLite] could not stage test.sqd: " + repr(_e)) + return _orig_read_raw_kit(input_fname, *args, **kwargs) + + +mne.io.read_raw_kit = _lite_read_raw_kit + +# === 7. What WebAssembly cannot do ========================================== +# No OS threads: the ProgressBar updater and tqdm's monitor only animate, so +# skip them (guarded, since both are private paths) +try: + from mne.utils import progressbar + + progressbar._UpdateThread.start = lambda self: None + progressbar._UpdateThread.join = lambda self, *args, **kwargs: None +except Exception: + pass +try: + import tqdm + + tqdm.tqdm.monitor_interval = 0 +except Exception: + pass + +import IPython + +IPython.get_ipython().run_line_magic("matplotlib", "inline") + +# fig.show() warns on the inline Agg canvas, and a few tutorials call it +import matplotlib.figure as mpl_figure + +mpl_figure.Figure.show = lambda self, *a, **k: None + +# A plot that is also a cell's last expression returns its Figure, which Out[] +# would echo a second time after plt_show displayed it. Drop that echo for +# Figures and lists of them (guarded: a double render beats a broken cell). +try: + _lite_dh = type(IPython.get_ipython().displayhook) + _lite_dh_call = _lite_dh.__call__ + + def _lite_displayhook(self, result=None): + _figs = result if isinstance(result, (list, tuple)) else [result] + if _figs and all(isinstance(_f, mpl_figure.Figure) for _f in _figs): + result = None + return _lite_dh_call(self, result) + + _lite_dh.__call__ = _lite_displayhook +except Exception: + pass + +# threadpoolctl 3.6.0 calls Pyodide's deprecated JsProxy.as_object_map(), which +# warns from mne.sys_info(); as_py_json() gives the same paths. +# TODO VERSION: fixed in joblib/threadpoolctl#201, drop once Pyodide bundles +# threadpoolctl >= 3.7.0 +try: + import threadpoolctl + + def _find_libraries_pyodide(self): + from pyodide_js._module import LDSO + + for _fp in LDSO.loadedLibsByName.as_py_json(): + if Path(_fp).exists(): + self._make_controller_from_path(_fp) + + threadpoolctl.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass + +# === 8. 3D ================================================================== +# VTK has no WebAssembly build, so draw with pyvista-js (vtk.js) instead; see +# mne/viz/backends/_lite.py +try: + mne.viz.set_3d_backend("jupyterlite_notebook") +except Exception as _e: + print("[JupyterLite] could not select the pyvista-js renderer: " + repr(_e)) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py deleted file mode 100644 index 76584ec1ec3..00000000000 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Turn on MNE's pyvista-js 3D renderer inside the JupyterLite kernel. - -VTK has no WebAssembly build, so the browser draws with pyvista-js instead. The -renderer itself is ordinary library code in ``mne/viz/backends/_lite.py``; this -module only exposes the few lines of notebook code that switch MNE over to it. -``LITE_RENDERER_CELL`` is appended to ``LITE_SETUP_CELL`` in -``jupyterlite_setup_cell.py``, which the docs build prepends to each notebook. -""" - -# Authors: The MNE-Python contributors. -# License: BSD-3-Clause -# Copyright the MNE-Python contributors. - -LITE_RENDERER_CELL = """ -# Using pyvista-js (vtk.js) to draw MNE's 3D rendering in JupyterLite. -# See mne/viz/backends/_lite.py for more details. -try: - import mne.viz - - mne.viz.set_3d_backend("jupyterlite_notebook") -except Exception as _e: - print("[JupyterLite] could not select the pyvista-js renderer: " + repr(_e)) -""" diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py new file mode 100644 index 00000000000..8e2a4076fd7 --- /dev/null +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -0,0 +1,46 @@ +"""The setup cell prepended to every JupyterLite notebook. + +It installs MNE into the browser kernel and patches what Pyodide does not +provide: data fetching over HTTP, the readers that expect files already on +disk, and the 3D renderer. The cell lives in ``_lite_setup_cell.py`` as +ordinary Python, so ruff lints and formats it; this module only reads that +file and checks it compiles. + +The docs build prepends it only to the notebooks copied into the JupyterLite +contents. It deliberately does NOT go through ``first_notebook_cell``: that is +applied when the notebook is generated, so it would also land in the ``.ipynb`` +offered for download, where ``piplite`` does not exist and the notebook would +fail on its first cell. + +The other direction is covered in the cell itself: a notebook downloaded from +inside JupyterLite does carry the cell, and it says to delete it before running +locally, for the same reason. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import ast +from pathlib import Path + +# The source file is split at this banner: everything after it is what the +# notebook runs, and what sits above it (license header, ruff directives, notes +# for whoever edits it) stays behind. +_BANNER = "# --- JupyterLite setup cell" + + +def _read(name): + _source = Path(__file__).parent / name + _text = _source.read_text() + if _BANNER not in _text: + raise RuntimeError(f"{_source.name} is missing the {_BANNER!r} banner") + _body = _text[_text.index(_BANNER) :] + return _body[_body.index("\n") + 1 :] + + +LITE_SETUP_CELL = _read("_lite_setup_cell.py") +# nothing else runs this before a reader does, so at least make sure it parses +compile( + LITE_SETUP_CELL, "lite_setup_cell", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT +) diff --git a/mne/event.py b/mne/event.py index f266b64b5a7..16122098c0a 100644 --- a/mne/event.py +++ b/mne/event.py @@ -5,7 +5,6 @@ # Copyright the MNE-Python contributors. from collections.abc import Sequence -from pathlib import Path import numpy as np @@ -281,7 +280,7 @@ def read_events( "-annot.fif", # MNE-C annot ), ) - filename = Path(filename) + filename = _check_fname(filename, "read", must_exist=True, name="Events file") if filename.suffix in (".fif", ".gz"): fid, tree, _ = fiff_open(filename) with fid as f: diff --git a/mne/label.py b/mne/label.py index 10cb1c118ce..f81d9a001ed 100644 --- a/mne/label.py +++ b/mne/label.py @@ -1172,6 +1172,7 @@ def read_label(filename, subject=None, color=None, *, verbose=None): """ if subject is not None and not isinstance(subject, str): raise TypeError("subject must be a string") + filename = _check_fname(filename, "read", must_exist=True, name="Label file") # find hemi basename = op.basename(filename) diff --git a/mne/surface.py b/mne/surface.py index 52b6cb754c9..ecf2aff057c 100644 --- a/mne/surface.py +++ b/mne/surface.py @@ -888,6 +888,7 @@ def read_curvature(filepath, binary=True): curv : array of shape (n_vertices,) The curvature values loaded from the user given file. """ + filepath = _check_fname(filepath, "read", must_exist=True, name="Curvature file") with open(filepath, "rb") as fobj: magic = _fread3(fobj) if magic == 16777215: diff --git a/mne/transforms.py b/mne/transforms.py index 7358032c248..76f25c61787 100644 --- a/mne/transforms.py +++ b/mne/transforms.py @@ -498,9 +498,7 @@ def _get_trans(trans, fro="mri", to="head", allow_none=True, *, extra=""): if _path_like(trans): if trans == "fsaverage": trans = Path(__file__).parent / "data" / "fsaverage" / "fsaverage-trans.fif" - trans = Path(trans) - if not trans.is_file(): - raise OSError(f'trans file "{trans}" not found') + trans = _check_fname(trans, "read", must_exist=True, name="trans file") if trans.suffix in [".fif", ".gz"]: fro_to_t = read_trans(trans) else: diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index ccbfc174f7e..17fdb975120 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2526,7 +2526,10 @@ def _check_st_tv(show_traces, time_viewer, times): extra="when a string", ) if time_viewer == "auto": - time_viewer = True + from .backends.renderer import _get_3d_backend + + # the browser backend writes a static scene, so there is no slider to show + time_viewer = _get_3d_backend() != "jupyterlite_notebook" if show_traces == "auto": show_traces = time_viewer and times is not None and len(times) > 1 if show_traces and not time_viewer: diff --git a/mne/viz/_brain/surface.py b/mne/viz/_brain/surface.py index d4ee918b9b1..f89a2e38370 100644 --- a/mne/viz/_brain/surface.py +++ b/mne/viz/_brain/surface.py @@ -169,9 +169,9 @@ def z(self): def load_curvature(self): """Load in curvature values from the ?h.curv file.""" curv_path = path.join(self.data_path, "surf", f"{self.hemi}.curv") - if path.isfile(curv_path): + try: self.curv = read_curvature(curv_path, binary=False) - self.bin_curv = np.array(self.curv > 0, np.int64) + except FileNotFoundError: + self.curv = self.bin_curv = None else: - self.curv = None - self.bin_curv = None + self.bin_curv = np.array(self.curv > 0, np.int64) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 79a8dc60543..97b25bcf3d3 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -5,10 +5,11 @@ a browser kernel, where VTK cannot load. Selected with ``mne.viz.set_3d_backend("jupyterlite_notebook")``. -Supported: meshes, surfaces, spheres, tubes and glyphs, which covers the static -figures. Not supported: :class:`mne.viz.Brain` (needs dock widgets), scalars, -colormaps and contours (the vtk.js template builds no lookup table, so every -mesh is one solid color), and figure size (pyvista-js writes a 600x400 canvas). +Supported: meshes, surfaces, spheres, tubes and glyphs, plus per-vertex RGB(A) +colors, which is how :class:`mne.viz.Brain` paints its surface, so ``stc.plot()`` +gives a static picture (one time point, no time viewer, colorbar or split +layout). Not supported: scalar colormaps and contours, and figure size +(pyvista-js writes a 600x400 canvas). """ # Authors: The MNE-Python contributors. @@ -27,6 +28,7 @@ _cart_to_sph, _find_vector_rotation, _sph_to_cart, + apply_trans, quat_to_rot, ) from ...utils import _check_option, _validate_type @@ -59,31 +61,36 @@ def _lite_unsupported(what): def _lite_add_text(plotter, text, position, size, color): actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) - actor.prop.font_size = int(size) + actor.prop.font_size = 14 if size is None else int(size) # Brain passes None actor.prop.color = _rgb(color) plotter.add_text(actor) return actor -def _lite_view_angles(plotter): +def _lite_view_angles(plotter, rigid=None): """Return the (azimuth, elevation) in degrees the plotter looks from, or None. The view is kept as ``view_vector``, a camera position that vtk.js aims at the origin and then frames with ``resetCamera()``, rather than as a camera object, which would need the distance that MNE mostly passes as None. + ``rigid`` is the frame the angles are expressed in (Brain's canonical + rotation), as in ``_pyvista._get_user_camera_direction``. """ view_vector = plotter._renderer._view_vector # pyvista-js 0.15 if view_vector is None: # nothing set yet, so vtk.js chooses return None - _, phi, theta = _cart_to_sph(np.asarray(view_vector, float)[np.newaxis])[0] + position = np.asarray(view_vector, float) + if rigid is not None: + position = apply_trans(rigid, position, move=False) + _, phi, theta = _cart_to_sph(position[np.newaxis])[0] return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180 -def _lite_set_view(plotter, azimuth=None, elevation=None): +def _lite_set_view(plotter, azimuth=None, elevation=None, rigid=None): """Point the plotter, keeping the angle not given as _pyvista._set_3d_view does.""" if azimuth is None and elevation is None: return - current = _lite_view_angles(plotter) or (90.0, 90.0) # plot_alignment's view + current = _lite_view_angles(plotter, rigid) or (90.0, 90.0) # plot_alignment phi = np.deg2rad(current[0] if azimuth is None else azimuth) theta = np.deg2rad(current[1] if elevation is None else elevation) # view up flips near the poles, matching _set_3d_view @@ -92,14 +99,17 @@ def _lite_set_view(plotter, azimuth=None, elevation=None): if elevation is None or 5 <= abs(elevation) <= 175 else (0.0, 1.0, 0.0) ) - plotter.view_vector( - tuple(_sph_to_cart(np.array([[1.0, phi, theta]]))[0]), viewup=up - ) + position = _sph_to_cart(np.array([[1.0, phi, theta]]))[0] + if rigid is not None: + rigid_inv = np.linalg.inv(rigid) + position = apply_trans(rigid_inv, position, move=False) + up = apply_trans(rigid_inv, up, move=False) + plotter.view_vector(tuple(position), viewup=tuple(up)) -def _lite_get_view(plotter): +def _lite_get_view(plotter, rigid=None): """Return (roll, distance, azimuth, elevation, focalpoint) as _get_3d_view does.""" - azimuth, elevation = _lite_view_angles(plotter) or (0.0, 0.0) + azimuth, elevation = _lite_view_angles(plotter, rigid) or (0.0, 0.0) return (0.0, 1.0, azimuth, elevation, np.zeros(3)) @@ -148,6 +158,20 @@ def _lite_revolve(profile, n_side): return np.vstack(rr), np.vstack(tris).astype(int) +class _LitePolyData(pv.PolyData): + """A mesh whose ``mesh["Data"] = colors`` also takes float RGB(A) in [0, 1]. + + Brain's LayeredMesh recolors its surface that way, which PyVista accepts; + vtk.js only uses an array as colors directly when it is uint8. + """ + + def __setitem__(self, name, array): + array = np.asarray(array) + if array.ndim == 2 and array.shape[1] in (3, 4) and array.dtype != np.uint8: + array = np.round(np.clip(array, 0, 1) * 255).astype(np.uint8) + super().__setitem__(name, array) + + class _LiteFigure(Figure3D): """pyvista-js-based 3D figure; ``.plotter`` is the pyvista-js plotter.""" @@ -267,24 +291,28 @@ def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): tris = np.asarray(tris, int)[np.newaxis] + offsets return points.reshape(-1, 3), tris.reshape(-1, 3) - def _add(self, points, tris, color, opacity=1.0): - """Draw one solid-color mesh and return MNE's (actor, mesh) pair.""" + def _add(self, points, tris, color, opacity=1.0, colors=None): + """Draw one mesh, solid or colored per vertex, and return (actor, mesh).""" # float32 halves the WASM cost, and vtk.js is single precision anyway; # the faces go over flat because vtk.js reads one VTK cell array - mesh = pv.PolyData( + mesh = _LitePolyData( points=np.asarray(points, np.float32), faces=_vtk_faces(tris).ravel() ) + kwargs = dict(color=_rgb(color)) + if colors is not None: # "Data" is the array name PyVista would use + mesh["Data"] = colors + kwargs["scalars"] = "Data" actor = self.plotter.add_mesh( mesh, - color=_rgb(color), opacity=1.0 if opacity is None else float(opacity), smooth_shading=True, + **kwargs, ) return actor, mesh # -- drawing ------------------------------------------------------------ # The signatures follow _PyVistaRenderer's so positional calls bind alike; - # scalars, colormaps, culling, normals and names are accepted and ignored. + # 1D scalars, colormaps, culling, normals and names are accepted and ignored. def mesh( self, x, @@ -307,7 +335,13 @@ def mesh( **kwargs, ): points = np.column_stack([np.ravel(x), np.ravel(y), np.ravel(z)]) - return self._add(points, triangles, color, opacity) + # per-vertex RGB(A) colors are drawn as given (Brain's LayeredMesh) + rgba = ( + scalars is not None + and np.ndim(scalars) == 2 + and np.shape(scalars)[1] in (3, 4) + ) + return self._add(points, triangles, color, opacity, scalars if rgba else None) def surface( self, @@ -509,8 +543,8 @@ def text2d( justification=None, font_file=None, ): - if justification is not None or font_file is not None: - _lite_unsupported("Justified text and custom fonts") + # justification and font_file only place and style the text, and vtk.js + # draws at a point in the page font; Brain's time label passes both return _lite_add_text(self.plotter, text, (x_window, y_window), size, color) def remove_mesh(self, mesh_data): @@ -528,6 +562,23 @@ def remove_mesh(self, mesh_data): def set_interaction(self, interaction): pass # vtk.js ships one trackball style + def _window_set_theme(self, theme): + pass # no window, so no widgets to theme + + def _set_colormap_range( + self, actor, ctable, scalar_bar, rng=None, background_color=None, fmt=None + ): + pass # colors arrive already mapped, per vertex + + def scalarbar( + self, source, color="white", title=None, n_labels=4, bgcolor=None, **kwargs + ): + return None, None # nothing to draw one with; Brain unpacks (bar, ticks) + + def subplot(self, x, y): + if (x, y) != (0, 0): + _lite_unsupported("Subplots") # one scene is one canvas + def _update(self): pass # the page paints after the cell finishes @@ -544,15 +595,9 @@ def close(self): def contour(self, *args, **kwargs): _lite_unsupported("Drawing contours") # one color would mislead - def scalarbar(self, *args, **kwargs): - _lite_unsupported("Drawing a scalar bar") - def legend(self, *args, **kwargs): _lite_unsupported("Drawing a legend") - def subplot(self, *args, **kwargs): - _lite_unsupported("Subplots") - def _process_events(self, *args, **kwargs): _lite_unsupported("Draining the event loop") # the page runs it @@ -570,7 +615,7 @@ def screenshot(self, mode="rgb", filename=None): # -- camera ------------------------------------------------------------- def get_camera(self, *, rigid=None): - return _lite_get_view(self.plotter) + return _lite_get_view(self.plotter, rigid) def set_camera( self, @@ -584,7 +629,7 @@ def set_camera( update=True, ): # distance, focalpoint and roll go unused: vtk.js frames the scene - _lite_set_view(self.plotter, azimuth, elevation) + _lite_set_view(self.plotter, azimuth, elevation, rigid) # -- the module surface renderer.py expects of a 3D backend ----------------- @@ -602,7 +647,7 @@ def _set_3d_view( rigid=None, update=True, ): - _lite_set_view(figure.plotter, azimuth, elevation) + _lite_set_view(figure.plotter, azimuth, elevation, rigid) def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index c3acdcab528..281665b2c76 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -93,10 +93,11 @@ def set_3d_backend(backend_name, verbose=None): not a desktop choice: it draws with vtk.js rather than VTK, which has no WebAssembly build, and it is what the documentation's browser notebooks run on. It covers the static 3D figures, so :func:`plot_alignment` (without - channel-name labels) and :func:`plot_sparse_source_estimates` work, while - :class:`mne.viz.Brain`, :func:`plot_evoked_field` and - :func:`snapshot_brain_montage` do not. On a desktop the other two are better - in every way, so it is never selected automatically. + channel-name labels) and :func:`plot_sparse_source_estimates` work, and + :class:`mne.viz.Brain` draws a single time point with no time viewer, + colorbar or split-hemisphere layout, while :func:`plot_evoked_field` and + :func:`snapshot_brain_montage` do not work. On a desktop the other two are + better in every way, so it is never selected automatically. This table shows the capabilities of each backend ("✓" for full support, and "-" for partial support): diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 3e3bb6ce2f1..849adde6b8a 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -12,12 +12,15 @@ from matplotlib.font_manager import findfont from numpy.testing import assert_allclose +from mne.datasets import testing from mne.transforms import quat_to_rot, rot_to_quat from mne.utils import run_subprocess from mne.viz import Figure3D, get_3d_backend, set_3d_backend from mne.viz.backends._utils import ALLOWED_QUIVER_MODES from mne.viz.backends.renderer import _get_renderer +_data_path = testing.data_path(download=False) + def _unsupported(renderer): """Return a context for what the browser backend says it cannot draw.""" @@ -194,27 +197,24 @@ def test_3d_backend(renderer): ) # scalar bar - with _unsupported(renderer): - rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) + rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) # use text - with _unsupported(renderer): - rend.text2d( - x_window=txt_x, - y_window=txt_y, - text=txt_text, - size=txt_size, - justification="right", - ) + rend.text2d( + x_window=txt_x, + y_window=txt_y, + text=txt_text, + size=txt_size, + justification="right", + ) # test font_file passthrough with a real font from matplotlib font_path = findfont("serif") - with _unsupported(renderer): - rend.text2d( - x_window=txt_x + 0.1, - y_window=txt_y + 0.1, - text="font test", - font_file=font_path, - ) + rend.text2d( + x_window=txt_x + 0.1, + y_window=txt_y + 0.1, + text="font test", + font_file=font_path, + ) rend.text3d(x=0, y=0, z=0, text=txt_text, font_size=12) rend.set_camera( azimuth=180.0, elevation=90.0, distance=cam_distance, focalpoint=center @@ -522,3 +522,31 @@ def test_lite_notebook_kernel(renderer_lite, nbexec): np.testing.assert_allclose(scene["camera"]["viewVector"], [0, 1, 0], atol=1e-12) html = rend.plotter.generate_standalone_html() # what the page will run assert json.dumps(source["points"]).replace(" ", "") in html.replace(" ", "") + + +@testing.requires_testing_data +def test_lite_brain(renderer_lite): + """Test Brain draws a static, per-vertex-colored surface through the backend.""" + import mne + + stc = mne.read_source_estimate( + _data_path / "MEG" / "sample" / "sample_audvis_trunc-meg", "sample" + ) + kwargs = dict(subject="sample", subjects_dir=_data_path / "subjects", hemi="lh") + brain = stc.plot(views="lat", initial_time=0.1, **kwargs) # time_viewer="auto" + assert isinstance(brain, mne.viz.Brain) + assert brain.time_viewer is False and brain._scalar_bar is None + (actor,) = brain._renderer.plotter.actors + colors = actor["mesh"].point_data["Data"] + # curvature plus activation, as uint8 RGBA vtk.js uses directly + assert colors.dtype == np.uint8 and colors.shape == (len(brain.geo["lh"].coords), 4) + assert len(np.unique(colors, axis=0)) > 2 + scene = brain._renderer.plotter._renderer._build_scene_data() + assert scene["actors"][0]["scalars"]["direct"] is True + # Brain's canonical rotation reaches the camera through `rigid` + assert brain._renderer.get_camera(rigid=brain._rigid)[2:4] == pytest.approx( + (180.0, 90.0) + ) + brain.close() + with pytest.raises(NotImplementedError, match="browser"): # two columns + mne.viz.Brain(surf="inflated", **{**kwargs, "hemi": "split"}) diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py index 25df95b6e2f..beb8c415368 100644 --- a/mne/viz/ui_events.py +++ b/mne/viz/ui_events.py @@ -283,7 +283,7 @@ def _get_event_channel(fig): names to a dict of callbacks (used as an ordered set) representing all subscribers to the channel, in the order in which they subscribed. """ - import matplotlib + import matplotlib.figure from ._brain import Brain from .evoked_field import EvokedField diff --git a/pyproject.toml b/pyproject.toml index 29c4b6c5486..85b43151b10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ test_extra = [ # marker is pyvista-js's own requires-python (>= 3.12, < 3.15) rather than # anything about JupyterLite: without it this group cannot resolve at all on # the 3.11 MNE still supports. Every CI job that installs it is on 3.12+. - "pyvista-js >= 0.15; python_version >= '3.12'", + "pyvista-js >= 0.16; python_version >= '3.12'", "statsmodels", {include-group = "test_extra_ft"}, ]