From 6183f2448d2de79f48413d2a280ea61e8330b5a6 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:26:12 -0400 Subject: [PATCH 01/14] ENH: add the JupyterLite notebook setup cell Installs MNE into the browser kernel with piplite and patches what Pyodide does not provide: HTTP data fetching, the readers that expect a file on disk, and a few things that need OS threads. --- doc/sphinxext/jupyterlite_setup_cell.py | 896 ++++++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 doc/sphinxext/jupyterlite_setup_cell.py diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py new file mode 100644 index 00000000000..10729a27cb8 --- /dev/null +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -0,0 +1,896 @@ +"""The setup cell prepended to every JupyterLite notebook. + +This installs MNE into the browser kernel and patches the bits of the +environment Pyodide does not provide: data fetching over HTTP, the readers +that expect files already on disk, and the 3D renderer. + +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. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +from jupyterlite_lite_renderer import LITE_RENDERER_CELL + +LITE_SETUP_CELL = ( + "# ๐Ÿ’ก This cell is automatically added to the start of each notebook.\n" + "# It installs MNE and patches the browser environment for Pyodide.\n" + "import piplite\n" + "# Use piplite (not micropip) so the locally-built development MNE wheel\n" + "# bundled into the JupyterLite build is preferred over the older PyPI\n" + "# release;\n" + "# piplite checks the local index first and falls back to PyPI for deps.\n" + "# keep_going=True lets it install even if Pyodide's bundled\n" + "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "await piplite.install(\n" + " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " + "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " + "'python-picard'],\n" + " keep_going=True,\n" + ")\n" + "\n" + "import sys\n" + "import os\n" + "import io\n" + "\n" + "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" + "try:\n" + " import lzma\n" + "except ImportError:\n" + " class _LZMAFile:\n" + " def __init__(self, *a, **kw): pass\n" + " def __enter__(self): return self\n" + " def __exit__(self, *a): pass\n" + " def write(self, d): pass\n" + " def read(self, n=-1): return b''\n" + " def close(self): pass\n" + " class _MockLZMA:\n" + " LZMAError = Exception\n" + " LZMAFile = _LZMAFile\n" + " FORMAT_XZ = 1\n" + " FORMAT_ALONE = 2\n" + " def __getattr__(self, name): return object\n" + " import sys as _sys\n" + " _sys.modules['lzma'] = _MockLZMA()\n" + "\n" + "# Mock multiprocessing โ€” missing in Pyodide but imported by joblib\n" + "from unittest.mock import MagicMock\n" + "if 'multiprocessing' not in sys.modules:\n" + " m = MagicMock()\n" + " m.cpu_count.return_value = 1\n" + " sys.modules['multiprocessing'] = m\n" + " sys.modules['multiprocessing.util'] = m.util\n" + " sys.modules['multiprocessing.pool'] = m.pool\n" + "\n" + "# Patch requests so pooch can fetch files already on /drive/mne_data.\n" + "# open_url works for both text and binary in Pyodide >= 0.21.\n" + "import requests\n" + "import pyodide\n" + "orig_send = requests.Session.send\n" + "def pyodide_send(self, request, **kwargs):\n" + " try:\n" + " buf = pyodide.http.open_url(request.url)\n" + " content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n" + " if isinstance(content, str):\n" + " content = content.encode('utf-8')\n" + " except Exception as e:\n" + " print(f'open_url failed for {request.url}: {e}')\n" + " return orig_send(self, request, **kwargs)\n" + " response = requests.Response()\n" + " response.status_code = 200\n" + " response.url = request.url\n" + " response.raw = io.BytesIO(content)\n" + " return response\n" + "requests.Session.send = pyodide_send\n" + "\n" + "# /drive/ in Pyodide requires Cross-Origin-Isolation headers\n" + "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" + "# do not send. Fetch the data over HTTP into /tmp/mne_data instead\n" + "# โ€” same-origin, no CORS. The data is served at the docs root\n" + "# (/mne_data/...) via Sphinx html_extra_path.\n" + "# Pyodide may run in a web worker (no `window`); `location` exists\n" + "# in both the main thread and workers, so use it to find the docs\n" + "# root by splitting on '/lite/'.\n" + "import pyodide.http as _phttp\n" + "import js as _js\n" + "try:\n" + " _page = str(_js.location.href)\n" + "except Exception:\n" + " _page = str(_js.window.location.href)\n" + "_base = _page.split('/lite/')[0] + '/mne_data/'\n" + "mne_data_path = '/tmp/mne_data'\n" + "_sample_dir = mne_data_path + '/MNE-sample-data'\n" + "# Eager 'core': small, commonly-used sample files fetched once at\n" + "# notebook start. The heavy files (raw / filt raw / ernoise / fwd /\n" + "# inv / src, ~360 MB total) are intentionally omitted here -- they are\n" + "# fetched lazily on first read via the reader shims below, so each\n" + "# notebook only downloads the sample files it actually uses.\n" + "_sample_files = [\n" + " 'version.txt',\n" + " 'MEG/sample/sample_audvis_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_filt-0-40_raw-eve.fif',\n" + " 'MEG/sample/sample_audvis_ecg-proj.fif',\n" + " 'MEG/sample/sample_audvis-cov.fif',\n" + " 'MEG/sample/sample_audvis-ave.fif',\n" + " 'MEG/sample/sample_audvis-no-filter-ave.fif',\n" + " 'MEG/sample/sample_audvis_raw-trans.fif',\n" + " 'MEG/sample/sample_audvis-shrunk-cov.fif',\n" + " 'MEG/sample/sample_audvis-meg-lh.stc',\n" + " 'MEG/sample/sample_audvis-meg-rh.stc',\n" + " 'subjects/sample/mri/T1.mgz',\n" + " 'subjects/sample/surf/rh.pial',\n" + " 'subjects/sample/surf/lh.pial',\n" + " 'subjects/sample/surf/rh.white',\n" + " 'subjects/sample/surf/lh.white',\n" + " 'subjects/sample/label/lh.aparc.annot',\n" + " 'subjects/sample/label/rh.aparc.annot',\n" + " 'SSS/sss_cal_mgh.dat',\n" + " 'SSS/ct_sparse_mgh.fif',\n" + "]\n" + "print('Fetching MNE sample data (once per session)...')\n" + "for _f in _sample_files:\n" + " _dst = _sample_dir + '/' + _f\n" + " if os.path.exists(_dst):\n" + " continue\n" + " _url = _base + 'MNE-sample-data/' + _f\n" + " try:\n" + " _r = await _phttp.pyfetch(_url)\n" + " if _r.status != 200:\n" + " print(f' HTTP {_r.status} for {_url}')\n" + " continue\n" + " _d = await _r.bytes()\n" + " if _d[:4] == b'=0)\n" + " _fc = _cv[_tris].mean(1)\n" + " for _cm, _col in (\n" + " (_fc < 0, (0.68, 0.68, 0.68)),\n" + " (_fc >= 0, (0.38, 0.38, 0.38))):\n" + " _s = _sub(_pts, _tris, _cm)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0], faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # activation as a smooth hot gradient in N value bands,\n" + " # each lifted 2% off the surface to avoid z-fighting\n" + " _fv = _scal[_tris].mean(1)\n" + " _p90 = _np.percentile(_scal, 90.0)\n" + " _fmax = float(_scal.max())\n" + " # keep the background gray: for sparse point sources the\n" + " # 90th pct is ~0 (most of the brain is zero), which would\n" + " # paint everything, so fall back to a fraction of the max.\n" + " _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4\n" + " if _fmax > _fmin:\n" + " _edges = _np.linspace(_fmin, _fmax, _N + 1)\n" + " for _i in range(_N):\n" + " if _i < _N - 1:\n" + " _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1])\n" + " else:\n" + " _m = _fv >= _edges[_i]\n" + " if int(_m.sum()) == 0:\n" + " continue\n" + " _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1)))\n" + " _col = (float(_rgb[0]), float(_rgb[1]),\n" + " float(_rgb[2]))\n" + " _s = _sub(_pts, _tris, _m, 0.02, _cen)\n" + " if _s is not None:\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_s[0],\n" + " faces=_flat(_s[1])),\n" + " color=_col, smooth_shading=True)\n" + " # Open on the lateral profile (camera along the medial-lateral\n" + " # X axis, superior up), like native MNE, instead of vtk.js's\n" + " # default anterior/face-on view. Guarded so a missing\n" + " # view_vector never costs us the render.\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js 3D render unavailable: '\n" + " + repr(_e))\n" + " return _LiteBrain()\n" + "mne.SourceEstimate.plot = _lite_stc_plot\n" + "\n" + "# Pyodide/WASM has no OS threads, so MNE's ProgressBar background\n" + "# updater thread (used by the ProgressBar context manager, e.g. in\n" + "# permutation cluster tests) crashes with 'can't start new thread'.\n" + "# That thread only animates a cosmetic bar โ€” the computation runs on\n" + "# the main thread and __exit__ writes the final state โ€” so no-op its\n" + "# start/join. Only affects notebooks that use it; results are unchanged.\n" + "try:\n" + " from mne.utils import progressbar as _mpb\n" + " _mpb._UpdateThread.start = lambda self: None\n" + " _mpb._UpdateThread.join = lambda self, *_a, **_kw: None\n" + "except Exception:\n" + " pass\n" + "# tqdm also spawns its own monitor thread, which likewise can't start in\n" + "# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before\n" + "# any bar is created skips that thread entirely (bars still display).\n" + "try:\n" + " import tqdm as _tqdm\n" + " _tqdm.tqdm.monitor_interval = 0\n" + "except Exception:\n" + " pass\n" + "\n" + "# Switch matplotlib to inline so figures render in the notebook.\n" + "import IPython\n" + "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" + "import matplotlib.pyplot as plt\n" + "# Silence the spurious 'FigureCanvasAgg is non-interactive' warning\n" + "# at its source. MNE's plt_show calls fig.show() (the inline backend\n" + "# isn't detected as 'agg'), and the inline Agg canvas warns. Patching\n" + "# viz.utils.plt_show is not enough: other modules did\n" + "# `from .utils import plt_show` and hold their own reference. Every\n" + "# path resolves fig.show on the class at call time, so a no-op here\n" + "# silences it everywhere. Figures still render via the inline backend.\n" + "import matplotlib.figure as _mfig\n" + "_mfig.Figure.show = lambda self, *a, **k: None\n" + "import importlib\n" + "viz_utils = importlib.import_module('mne.viz.utils')\n" + "# Also display+close via IPython for paths that call plt_show\n" + "# directly, so figures render exactly once.\n" + "def pyodide_plt_show(show=True, fig=None, **kwargs):\n" + " if not show:\n" + " return\n" + " import IPython.display\n" + " _f = fig if fig is not None else plt.gcf()\n" + " IPython.display.display(_f)\n" + " plt.close(_f)\n" + "viz_utils.plt_show = pyodide_plt_show\n" + "\n" + "# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer\n" + "# BEFORE the time-course figure, so in WASM the whole call dies and the\n" + "# notebook loses both halves. Rebuild it here: the same glass brain from\n" + "# the source space and a marker per active dipole via pyvista-js, plus\n" + "# the matplotlib time courses (which are the quantitative half). Same\n" + "# approach as the SourceEstimate.plot shim above.\n" + "def _lite_plot_sparse_source_estimates(\n" + " src, stcs, colors=None, linewidth=2, fontsize=18,\n" + " bgcolor=(0.05, 0, 0.1), opacity=0.2, brain_color=(0.7,) * 3,\n" + " show=True, high_resolution=False, fig_name=None,\n" + " fig_number=None, labels=None, modes=('cone', 'sphere'),\n" + " scale_factors=(1, 0.6), **kwargs):\n" + " import numpy as _np\n" + " from itertools import cycle as _cycle\n" + " from matplotlib.colors import to_rgb as _to_rgb\n" + " if not isinstance(stcs, list):\n" + " stcs = [stcs]\n" + " _lhp = src[0]['rr']\n" + " _pts = _np.r_[_lhp, src[1]['rr']] * 170\n" + " _nrm = _np.r_[src[0]['nn'], src[1]['nn']]\n" + " # use_tris is the decimated mesh and can be None on some source\n" + " # spaces; fall back to the full tris in that case.\n" + " _lt = src[0]['tris'] if high_resolution else src[0]['use_tris']\n" + " _rt = src[1]['tris'] if high_resolution else src[1]['use_tris']\n" + " if _lt is None or _rt is None:\n" + " _lt, _rt = src[0]['tris'], src[1]['tris']\n" + " _faces = _np.r_[_lt, len(_lhp) + _rt]\n" + " _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno]\n" + " for _s in stcs]\n" + " _uniq = _np.unique(_np.concatenate(_vertnos).ravel())\n" + " # --- time courses -------------------------------------------------\n" + " _fig = plt.figure(fig_number, layout='constrained')\n" + " _fig.clf()\n" + " _ax = _fig.add_subplot(111)\n" + " _cyc = _cycle(colors if colors is not None else\n" + " plt.rcParams['axes.prop_cycle'].by_key()['color'])\n" + " _marks = []\n" + " for _v in _uniq:\n" + " _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn]\n" + " _c = next(_cyc)\n" + " _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1))\n" + " for _k in _ind:\n" + " _m = _vertnos[_k] == _v\n" + " _ax.plot(1e3 * stcs[_k].times,\n" + " 1e9 * stcs[_k].data[_m].ravel(),\n" + " c=_c, linewidth=linewidth)\n" + " _ax.set_xlabel('Time (ms)', fontsize=fontsize)\n" + " _ax.set_ylabel('Source amplitude (nAm)', fontsize=fontsize)\n" + " if fig_name is not None:\n" + " _ax.set_title(fig_name)\n" + " pyodide_plt_show(show)\n" + " # --- glass brain + dipole markers ---------------------------------\n" + " try:\n" + " import pyvista_js as _pv\n" + " _plotter = _pv.Plotter()\n" + " _plotter.background_color = tuple(\n" + " float(min(max(_x, 0.0), 1.0)) for _x in bgcolor)\n" + " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" + " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" + " _plotter.add_light(_pv.Light(\n" + " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" + " 300.0 * _lp[2]),\n" + " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" + " _flat_faces = _np.hstack([\n" + " _np.full((len(_faces), 1), 3, dtype=_np.int32),\n" + " _faces.astype(_np.int32)]).ravel()\n" + " _plotter.add_mesh(\n" + " _pv.PolyData(points=_pts.astype(_np.float32),\n" + " faces=_flat_faces),\n" + " color=tuple(float(_x) for _x in brain_color),\n" + " opacity=float(opacity), smooth_shading=True)\n" + " for _v, _col, _common in _marks:\n" + " _sf = float(scale_factors[1] if _common\n" + " else scale_factors[0])\n" + " _mode = modes[1] if _common else modes[0]\n" + " _xyz = tuple(float(_q) for _q in _pts[_v])\n" + " if _mode == 'sphere':\n" + " _glyph = _pv.Sphere(radius=_sf, center=_xyz)\n" + " else:\n" + " _glyph = _pv.Cone(\n" + " center=_xyz,\n" + " direction=tuple(float(_q) for _q in _nrm[_v]),\n" + " height=2.0 * _sf, radius=_sf)\n" + " _plotter.add_mesh(_glyph, color=_col, smooth_shading=True)\n" + " try:\n" + " _plotter.view_vector((-1.0, 0.0, 0.0),\n" + " viewup=(0.0, 0.0, 1.0))\n" + " except Exception:\n" + " pass\n" + " _plotter.show()\n" + " except Exception as _e:\n" + " print('[JupyterLite] pyvista-js glass brain unavailable: '\n" + " + repr(_e))\n" + "mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates\n" + "\n" + "# Each MNE plot is rendered once by pyodide_plt_show above (display()).\n" + "# When a plot call is also a cell's last expression, the method returns\n" + "# the Figure, which Jupyter echoes a SECOND time as the Out[] result\n" + "# (the duplicate seen below inline plots). Drop that redundant echo for\n" + "# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each\n" + "# plot appears exactly once. Non-figure results (numbers, DataFrames,\n" + "# reprs) are untouched, and raw matplotlib figures never shown still\n" + "# render via the inline backend's end-of-cell flush, so nothing hides.\n" + "# Wrapped in try/except (like the patches below): if anything about\n" + "# the displayhook is unexpected, silently keep the current behavior\n" + "# (harmless double render) rather than breaking the setup cell.\n" + "try:\n" + " _lite_dh = type(IPython.get_ipython().displayhook)\n" + " if not getattr(_lite_dh, '_lite_no_fig_echo', False):\n" + " _lite_dh_call = _lite_dh.__call__\n" + " def _lite_displayhook(self, result=None):\n" + " if isinstance(result, _mfig.Figure):\n" + " result = None\n" + " elif (isinstance(result, (list, tuple)) and result\n" + " and all(isinstance(_x, _mfig.Figure) for _x in result)):\n" + " result = None\n" + " return _lite_dh_call(self, result)\n" + " _lite_dh.__call__ = _lite_displayhook\n" + " _lite_dh._lite_no_fig_echo = True\n" + "except Exception:\n" + " pass\n" + "\n" + "# Real fix (not a warnings filter) for the threadpoolctl Pyodide\n" + "# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest\n" + "# release) still calls the deprecated Pyodide JsProxy.as_object_map().\n" + "# Pyodide's own message says to use as_py_json() instead; both yield the\n" + "# same library filepaths, so we swap the call at its source. This removes\n" + "# the deprecated API usage entirely, so the warning is never emitted.\n" + "# The upstream fix is already merged (joblib/threadpoolctl#201) but\n" + "# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH\n" + "# once threadpoolctl 3.7.0 is released and Pyodide bundles it.\n" + "try:\n" + " import os as _os\n" + " import threadpoolctl as _tpc\n" + " def _find_libraries_pyodide(self):\n" + " from pyodide_js._module import LDSO\n" + " for _fp in LDSO.loadedLibsByName.as_py_json():\n" + " if _os.path.exists(_fp):\n" + " self._make_controller_from_path(_fp)\n" + " _tpc.ThreadpoolController._find_libraries_pyodide = (\n" + " _find_libraries_pyodide\n" + " )\n" + "except Exception:\n" + " pass\n" + LITE_RENDERER_CELL + # Draw MNE's 3D figures with pyvista-js. Appended last so MNE is + # already imported; see doc/sphinxext/jupyterlite_lite_renderer.py. +) From 1e6e216f567d7dcd7ee6073769a87f32203bfa1c Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:27:04 -0400 Subject: [PATCH 02/14] DOC: add the changelog entry for the setup cell --- doc/changes/dev/14150.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14150.other.rst 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`_. From 6e49df16978498bd7128199f6666ceeefeefb120 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Fri, 14 Aug 2026 09:32:12 -0400 Subject: [PATCH 03/14] DOC: correct what keep_going does in the setup cell It controls whether a dependency with no pure-Python wheel aborts the install or is reported at the end. It was never about version bounds, and since the move to Pyodide 314 there are none left to clear anyway. --- doc/sphinxext/jupyterlite_setup_cell.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 10729a27cb8..5204ec7713c 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -25,8 +25,8 @@ "# bundled into the JupyterLite build is preferred over the older PyPI\n" "# release;\n" "# piplite checks the local index first and falls back to PyPI for deps.\n" - "# keep_going=True lets it install even if Pyodide's bundled\n" - "# matplotlib/scipy/numpy are older than MNE's declared minimums.\n" + "# keep_going=True so a dependency with no pure-Python wheel is reported\n" + "# at the end rather than aborting the whole install on the first one.\n" "await piplite.install(\n" " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " From 9045bfd614f48d6191398479afab89d4d3901a47 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 11:25:26 -0400 Subject: [PATCH 04/14] MAINT: move the JupyterLite setup cell into a real source file --- doc/sphinxext/_lite_setup_cell.py | 1092 +++++++++++++++++++++++ doc/sphinxext/jupyterlite_setup_cell.py | 901 +------------------ 2 files changed, 1113 insertions(+), 880 deletions(-) create mode 100644 doc/sphinxext/_lite_setup_cell.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py new file mode 100644 index 00000000000..ac80e878748 --- /dev/null +++ b/doc/sphinxext/_lite_setup_cell.py @@ -0,0 +1,1092 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# This file is notebook source rather than a module: it installs packages with +# a top-level ``await`` and imports them only afterwards, so the rules about +# import position, await position and import order do not apply to it. Ruff +# still lints and formats everything else here, which is the point of keeping +# it as a real file instead of a string. +# ruff: noqa: E402, F704, I001 + +# --- JupyterLite setup cell ------------------------------------------------- +# ๐Ÿ’ก This cell is automatically added to the start of each notebook. +# It installs MNE and patches the browser environment for Pyodide. +import piplite + +# Use piplite (not micropip) so the locally-built development MNE wheel +# bundled into the JupyterLite build is preferred over the older PyPI +# release; +# piplite checks the local index first and falls back to PyPI for deps. +# keep_going=True so a dependency with no pure-Python wheel is reported +# at the end rather than aborting the whole install on the first one. +await piplite.install( + [ + "mne", + "scikit-learn", + "joblib", + "pandas", + "seaborn", + "mne-connectivity", + "nibabel", + "pyvista-js", + "pyxdf", + "mffpy", + "python-picard", + ], + keep_going=True, +) + +import sys +import os +import io + +# lzma: try real stdlib first (Pyodide ships it); only mock if absent. The +# import has to be attempted rather than probed with find_spec, because the +# mock below is only installed when it actually fails. +try: + import lzma # noqa: F401 +except ImportError: + + class _LZMAFile: + def __init__(self, *a, **kw): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + def write(self, d): + pass + + def read(self, n=-1): + return b"" + + def close(self): + pass + + class _MockLZMA: + LZMAError = Exception + LZMAFile = _LZMAFile + FORMAT_XZ = 1 + FORMAT_ALONE = 2 + + def __getattr__(self, name): + return object + + import sys as _sys + + _sys.modules["lzma"] = _MockLZMA() + +# Mock multiprocessing โ€” missing in Pyodide but imported by joblib +from unittest.mock import MagicMock + +if "multiprocessing" not in sys.modules: + m = MagicMock() + m.cpu_count.return_value = 1 + sys.modules["multiprocessing"] = m + sys.modules["multiprocessing.util"] = m.util + sys.modules["multiprocessing.pool"] = m.pool + +# Patch requests so pooch can fetch files already on /drive/mne_data. +# open_url works for both text and binary in Pyodide >= 0.21. +import requests +import pyodide + +orig_send = requests.Session.send + + +def pyodide_send(self, request, **kwargs): + try: + buf = pyodide.http.open_url(request.url) + content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() + if isinstance(content, str): + content = content.encode("utf-8") + except Exception as e: + print(f"open_url failed for {request.url}: {e}") + return orig_send(self, request, **kwargs) + response = requests.Response() + response.status_code = 200 + response.url = request.url + response.raw = io.BytesIO(content) + return response + + +requests.Session.send = pyodide_send + +# /drive/ in Pyodide requires Cross-Origin-Isolation headers +# (COOP/COEP) which many static servers (e.g. CircleCI artifacts) +# do not send. Fetch the data over HTTP into /tmp/mne_data instead +# โ€” same-origin, no CORS. The data is served at the docs root +# (/mne_data/...) via Sphinx html_extra_path. +# Pyodide may run in a web worker (no `window`); `location` exists +# in both the main thread and workers, so use it to find the docs +# root by splitting on '/lite/'. +import pyodide.http as _phttp +import js as _js + +try: + _page = str(_js.location.href) +except Exception: + _page = str(_js.window.location.href) +_base = _page.split("/lite/")[0] + "/mne_data/" +mne_data_path = "/tmp/mne_data" +_sample_dir = mne_data_path + "/MNE-sample-data" +# Eager 'core': small, commonly-used sample files fetched once at +# notebook start. The heavy files (raw / filt raw / ernoise / fwd / +# inv / src, ~360 MB total) are intentionally omitted here -- they are +# fetched lazily on first read via the reader shims below, so each +# notebook only downloads the sample files it actually uses. +_sample_files = [ + "version.txt", + "MEG/sample/sample_audvis_raw-eve.fif", + "MEG/sample/sample_audvis_filt-0-40_raw-eve.fif", + "MEG/sample/sample_audvis_ecg-proj.fif", + "MEG/sample/sample_audvis-cov.fif", + "MEG/sample/sample_audvis-ave.fif", + "MEG/sample/sample_audvis-no-filter-ave.fif", + "MEG/sample/sample_audvis_raw-trans.fif", + "MEG/sample/sample_audvis-shrunk-cov.fif", + "MEG/sample/sample_audvis-meg-lh.stc", + "MEG/sample/sample_audvis-meg-rh.stc", + "subjects/sample/mri/T1.mgz", + "subjects/sample/surf/rh.pial", + "subjects/sample/surf/lh.pial", + "subjects/sample/surf/rh.white", + "subjects/sample/surf/lh.white", + "subjects/sample/label/lh.aparc.annot", + "subjects/sample/label/rh.aparc.annot", + "SSS/sss_cal_mgh.dat", + "SSS/ct_sparse_mgh.fif", +] +print("Fetching MNE sample data (once per session)...") +for _f in _sample_files: + _dst = _sample_dir + "/" + _f + if os.path.exists(_dst): + continue + _url = _base + "MNE-sample-data/" + _f + try: + _r = await _phttp.pyfetch(_url) + if _r.status != 200: + print(f" HTTP {_r.status} for {_url}") + continue + _d = await _r.bytes() + if _d[:4] == b"=0) + _fc = _cv[_tris].mean(1) + for _cm, _col in ( + (_fc < 0, (0.68, 0.68, 0.68)), + (_fc >= 0, (0.38, 0.38, 0.38)), + ): + _s = _sub(_pts, _tris, _cm) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # activation as a smooth hot gradient in N value bands, + # each lifted 2% off the surface to avoid z-fighting + _fv = _scal[_tris].mean(1) + _p90 = _np.percentile(_scal, 90.0) + _fmax = float(_scal.max()) + # keep the background gray: for sparse point sources the + # 90th pct is ~0 (most of the brain is zero), which would + # paint everything, so fall back to a fraction of the max. + _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + if _fmax > _fmin: + _edges = _np.linspace(_fmin, _fmax, _N + 1) + for _i in range(_N): + if _i < _N - 1: + _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) + else: + _m = _fv >= _edges[_i] + if int(_m.sum()) == 0: + continue + _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) + _s = _sub(_pts, _tris, _m, 0.02, _cen) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # Open on the lateral profile (camera along the medial-lateral + # X axis, superior up), like native MNE, instead of vtk.js's + # default anterior/face-on view. Guarded so a missing + # view_vector never costs us the render. + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) + return _LiteBrain() + + +mne.SourceEstimate.plot = _lite_stc_plot + +# Pyodide/WASM has no OS threads, so MNE's ProgressBar background +# updater thread (used by the ProgressBar context manager, e.g. in +# permutation cluster tests) crashes with 'can't start new thread'. +# That thread only animates a cosmetic bar โ€” the computation runs on +# the main thread and __exit__ writes the final state โ€” so no-op its +# start/join. Only affects notebooks that use it; results are unchanged. +try: + from mne.utils import progressbar as _mpb + + _mpb._UpdateThread.start = lambda self: None + _mpb._UpdateThread.join = lambda self, *_a, **_kw: None +except Exception: + pass +# tqdm also spawns its own monitor thread, which likewise can't start in +# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before +# any bar is created skips that thread entirely (bars still display). +try: + import tqdm as _tqdm + + _tqdm.tqdm.monitor_interval = 0 +except Exception: + pass + +# Switch matplotlib to inline so figures render in the notebook. +import IPython + +IPython.get_ipython().run_line_magic("matplotlib", "inline") +import matplotlib.pyplot as plt + +# Silence the spurious 'FigureCanvasAgg is non-interactive' warning +# at its source. MNE's plt_show calls fig.show() (the inline backend +# isn't detected as 'agg'), and the inline Agg canvas warns. Patching +# viz.utils.plt_show is not enough: other modules did +# `from .utils import plt_show` and hold their own reference. Every +# path resolves fig.show on the class at call time, so a no-op here +# silences it everywhere. Figures still render via the inline backend. +import matplotlib.figure as _mfig + +_mfig.Figure.show = lambda self, *a, **k: None +import importlib + +viz_utils = importlib.import_module("mne.viz.utils") + + +# Also display+close via IPython for paths that call plt_show +# directly, so figures render exactly once. +def pyodide_plt_show(show=True, fig=None, **kwargs): + if not show: + return + import IPython.display + + _f = fig if fig is not None else plt.gcf() + IPython.display.display(_f) + plt.close(_f) + + +viz_utils.plt_show = pyodide_plt_show + + +# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer +# BEFORE the time-course figure, so in WASM the whole call dies and the +# notebook loses both halves. Rebuild it here: the same glass brain from +# the source space and a marker per active dipole via pyvista-js, plus +# the matplotlib time courses (which are the quantitative half). Same +# approach as the SourceEstimate.plot shim above. +def _lite_plot_sparse_source_estimates( + src, + stcs, + colors=None, + linewidth=2, + fontsize=18, + bgcolor=(0.05, 0, 0.1), + opacity=0.2, + brain_color=(0.7,) * 3, + show=True, + high_resolution=False, + fig_name=None, + fig_number=None, + labels=None, + modes=("cone", "sphere"), + scale_factors=(1, 0.6), + **kwargs, +): + import numpy as _np + from itertools import cycle as _cycle + from matplotlib.colors import to_rgb as _to_rgb + + if not isinstance(stcs, list): + stcs = [stcs] + _lhp = src[0]["rr"] + _pts = _np.r_[_lhp, src[1]["rr"]] * 170 + _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] + # use_tris is the decimated mesh and can be None on some source + # spaces; fall back to the full tris in that case. + _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] + _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] + if _lt is None or _rt is None: + _lt, _rt = src[0]["tris"], src[1]["tris"] + _faces = _np.r_[_lt, len(_lhp) + _rt] + _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] + _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) + # --- time courses ------------------------------------------------- + _fig = plt.figure(fig_number, layout="constrained") + _fig.clf() + _ax = _fig.add_subplot(111) + _cyc = _cycle( + colors + if colors is not None + else plt.rcParams["axes.prop_cycle"].by_key()["color"] + ) + _marks = [] + for _v in _uniq: + _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] + _c = next(_cyc) + _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) + for _k in _ind: + _m = _vertnos[_k] == _v + _ax.plot( + 1e3 * stcs[_k].times, + 1e9 * stcs[_k].data[_m].ravel(), + c=_c, + linewidth=linewidth, + ) + _ax.set_xlabel("Time (ms)", fontsize=fontsize) + _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) + if fig_name is not None: + _ax.set_title(fig_name) + pyodide_plt_show(show) + # --- glass brain + dipole markers --------------------------------- + try: + import pyvista_js as _pv + + _plotter = _pv.Plotter() + _plotter.background_color = tuple( + float(min(max(_x, 0.0), 1.0)) for _x in bgcolor + ) + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _flat_faces = _np.hstack( + [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] + ).ravel() + _plotter.add_mesh( + _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), + color=tuple(float(_x) for _x in brain_color), + opacity=float(opacity), + smooth_shading=True, + ) + for _v, _col, _common in _marks: + _sf = float(scale_factors[1] if _common else scale_factors[0]) + _mode = modes[1] if _common else modes[0] + _xyz = tuple(float(_q) for _q in _pts[_v]) + if _mode == "sphere": + _glyph = _pv.Sphere(radius=_sf, center=_xyz) + else: + _glyph = _pv.Cone( + center=_xyz, + direction=tuple(float(_q) for _q in _nrm[_v]), + height=2.0 * _sf, + radius=_sf, + ) + _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) + + +mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates + +# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# When a plot call is also a cell's last expression, the method returns +# the Figure, which Jupyter echoes a SECOND time as the Out[] result +# (the duplicate seen below inline plots). Drop that redundant echo for +# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each +# plot appears exactly once. Non-figure results (numbers, DataFrames, +# reprs) are untouched, and raw matplotlib figures never shown still +# render via the inline backend's end-of-cell flush, so nothing hides. +# Wrapped in try/except (like the patches below): if anything about +# the displayhook is unexpected, silently keep the current behavior +# (harmless double render) rather than breaking the setup cell. +try: + _lite_dh = type(IPython.get_ipython().displayhook) + if not getattr(_lite_dh, "_lite_no_fig_echo", False): + _lite_dh_call = _lite_dh.__call__ + + def _lite_displayhook(self, result=None): + if isinstance(result, _mfig.Figure): + result = None + elif ( + isinstance(result, (list, tuple)) + and result + and all(isinstance(_x, _mfig.Figure) for _x in result) + ): + result = None + return _lite_dh_call(self, result) + + _lite_dh.__call__ = _lite_displayhook + _lite_dh._lite_no_fig_echo = True +except Exception: + pass + +# Real fix (not a warnings filter) for the threadpoolctl Pyodide +# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest +# release) still calls the deprecated Pyodide JsProxy.as_object_map(). +# Pyodide's own message says to use as_py_json() instead; both yield the +# same library filepaths, so we swap the call at its source. This removes +# the deprecated API usage entirely, so the warning is never emitted. +# The upstream fix is already merged (joblib/threadpoolctl#201) but +# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH +# once threadpoolctl 3.7.0 is released and Pyodide bundles it. +try: + import os as _os + import threadpoolctl as _tpc + + def _find_libraries_pyodide(self): + from pyodide_js._module import LDSO + + for _fp in LDSO.loadedLibsByName.as_py_json(): + if _os.path.exists(_fp): + self._make_controller_from_path(_fp) + + _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 5204ec7713c..33cfa89ecae 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -1,8 +1,10 @@ """The setup cell prepended to every JupyterLite notebook. -This installs MNE into the browser kernel and patches the bits of the -environment Pyodide does not provide: data fetching over HTTP, the readers -that expect files already on disk, and the 3D renderer. +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 itself lives in ``_lite_setup_cell.py`` as +ordinary Python, so ruff lints and formats it; this module only reads that file +and exposes it as the string the browser kernel needs. 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 @@ -15,882 +17,21 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +from pathlib import Path + from jupyterlite_lite_renderer import LITE_RENDERER_CELL -LITE_SETUP_CELL = ( - "# ๐Ÿ’ก This cell is automatically added to the start of each notebook.\n" - "# It installs MNE and patches the browser environment for Pyodide.\n" - "import piplite\n" - "# Use piplite (not micropip) so the locally-built development MNE wheel\n" - "# bundled into the JupyterLite build is preferred over the older PyPI\n" - "# release;\n" - "# piplite checks the local index first and falls back to PyPI for deps.\n" - "# keep_going=True so a dependency with no pure-Python wheel is reported\n" - "# at the end rather than aborting the whole install on the first one.\n" - "await piplite.install(\n" - " ['mne', 'scikit-learn', 'joblib', 'pandas', 'seaborn', " - "'mne-connectivity', 'nibabel', 'pyvista-js', 'pyxdf', 'mffpy', " - "'python-picard'],\n" - " keep_going=True,\n" - ")\n" - "\n" - "import sys\n" - "import os\n" - "import io\n" - "\n" - "# lzma: try real stdlib first (Pyodide ships it); only mock if absent\n" - "try:\n" - " import lzma\n" - "except ImportError:\n" - " class _LZMAFile:\n" - " def __init__(self, *a, **kw): pass\n" - " def __enter__(self): return self\n" - " def __exit__(self, *a): pass\n" - " def write(self, d): pass\n" - " def read(self, n=-1): return b''\n" - " def close(self): pass\n" - " class _MockLZMA:\n" - " LZMAError = Exception\n" - " LZMAFile = _LZMAFile\n" - " FORMAT_XZ = 1\n" - " FORMAT_ALONE = 2\n" - " def __getattr__(self, name): return object\n" - " import sys as _sys\n" - " _sys.modules['lzma'] = _MockLZMA()\n" - "\n" - "# Mock multiprocessing โ€” missing in Pyodide but imported by joblib\n" - "from unittest.mock import MagicMock\n" - "if 'multiprocessing' not in sys.modules:\n" - " m = MagicMock()\n" - " m.cpu_count.return_value = 1\n" - " sys.modules['multiprocessing'] = m\n" - " sys.modules['multiprocessing.util'] = m.util\n" - " sys.modules['multiprocessing.pool'] = m.pool\n" - "\n" - "# Patch requests so pooch can fetch files already on /drive/mne_data.\n" - "# open_url works for both text and binary in Pyodide >= 0.21.\n" - "import requests\n" - "import pyodide\n" - "orig_send = requests.Session.send\n" - "def pyodide_send(self, request, **kwargs):\n" - " try:\n" - " buf = pyodide.http.open_url(request.url)\n" - " content = buf.getvalue() if hasattr(buf, 'getvalue') else buf.read()\n" - " if isinstance(content, str):\n" - " content = content.encode('utf-8')\n" - " except Exception as e:\n" - " print(f'open_url failed for {request.url}: {e}')\n" - " return orig_send(self, request, **kwargs)\n" - " response = requests.Response()\n" - " response.status_code = 200\n" - " response.url = request.url\n" - " response.raw = io.BytesIO(content)\n" - " return response\n" - "requests.Session.send = pyodide_send\n" - "\n" - "# /drive/ in Pyodide requires Cross-Origin-Isolation headers\n" - "# (COOP/COEP) which many static servers (e.g. CircleCI artifacts)\n" - "# do not send. Fetch the data over HTTP into /tmp/mne_data instead\n" - "# โ€” same-origin, no CORS. The data is served at the docs root\n" - "# (/mne_data/...) via Sphinx html_extra_path.\n" - "# Pyodide may run in a web worker (no `window`); `location` exists\n" - "# in both the main thread and workers, so use it to find the docs\n" - "# root by splitting on '/lite/'.\n" - "import pyodide.http as _phttp\n" - "import js as _js\n" - "try:\n" - " _page = str(_js.location.href)\n" - "except Exception:\n" - " _page = str(_js.window.location.href)\n" - "_base = _page.split('/lite/')[0] + '/mne_data/'\n" - "mne_data_path = '/tmp/mne_data'\n" - "_sample_dir = mne_data_path + '/MNE-sample-data'\n" - "# Eager 'core': small, commonly-used sample files fetched once at\n" - "# notebook start. The heavy files (raw / filt raw / ernoise / fwd /\n" - "# inv / src, ~360 MB total) are intentionally omitted here -- they are\n" - "# fetched lazily on first read via the reader shims below, so each\n" - "# notebook only downloads the sample files it actually uses.\n" - "_sample_files = [\n" - " 'version.txt',\n" - " 'MEG/sample/sample_audvis_raw-eve.fif',\n" - " 'MEG/sample/sample_audvis_filt-0-40_raw-eve.fif',\n" - " 'MEG/sample/sample_audvis_ecg-proj.fif',\n" - " 'MEG/sample/sample_audvis-cov.fif',\n" - " 'MEG/sample/sample_audvis-ave.fif',\n" - " 'MEG/sample/sample_audvis-no-filter-ave.fif',\n" - " 'MEG/sample/sample_audvis_raw-trans.fif',\n" - " 'MEG/sample/sample_audvis-shrunk-cov.fif',\n" - " 'MEG/sample/sample_audvis-meg-lh.stc',\n" - " 'MEG/sample/sample_audvis-meg-rh.stc',\n" - " 'subjects/sample/mri/T1.mgz',\n" - " 'subjects/sample/surf/rh.pial',\n" - " 'subjects/sample/surf/lh.pial',\n" - " 'subjects/sample/surf/rh.white',\n" - " 'subjects/sample/surf/lh.white',\n" - " 'subjects/sample/label/lh.aparc.annot',\n" - " 'subjects/sample/label/rh.aparc.annot',\n" - " 'SSS/sss_cal_mgh.dat',\n" - " 'SSS/ct_sparse_mgh.fif',\n" - "]\n" - "print('Fetching MNE sample data (once per session)...')\n" - "for _f in _sample_files:\n" - " _dst = _sample_dir + '/' + _f\n" - " if os.path.exists(_dst):\n" - " continue\n" - " _url = _base + 'MNE-sample-data/' + _f\n" - " try:\n" - " _r = await _phttp.pyfetch(_url)\n" - " if _r.status != 200:\n" - " print(f' HTTP {_r.status} for {_url}')\n" - " continue\n" - " _d = await _r.bytes()\n" - " if _d[:4] == b'=0)\n" - " _fc = _cv[_tris].mean(1)\n" - " for _cm, _col in (\n" - " (_fc < 0, (0.68, 0.68, 0.68)),\n" - " (_fc >= 0, (0.38, 0.38, 0.38))):\n" - " _s = _sub(_pts, _tris, _cm)\n" - " if _s is not None:\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_s[0], faces=_flat(_s[1])),\n" - " color=_col, smooth_shading=True)\n" - " # activation as a smooth hot gradient in N value bands,\n" - " # each lifted 2% off the surface to avoid z-fighting\n" - " _fv = _scal[_tris].mean(1)\n" - " _p90 = _np.percentile(_scal, 90.0)\n" - " _fmax = float(_scal.max())\n" - " # keep the background gray: for sparse point sources the\n" - " # 90th pct is ~0 (most of the brain is zero), which would\n" - " # paint everything, so fall back to a fraction of the max.\n" - " _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4\n" - " if _fmax > _fmin:\n" - " _edges = _np.linspace(_fmin, _fmax, _N + 1)\n" - " for _i in range(_N):\n" - " if _i < _N - 1:\n" - " _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1])\n" - " else:\n" - " _m = _fv >= _edges[_i]\n" - " if int(_m.sum()) == 0:\n" - " continue\n" - " _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1)))\n" - " _col = (float(_rgb[0]), float(_rgb[1]),\n" - " float(_rgb[2]))\n" - " _s = _sub(_pts, _tris, _m, 0.02, _cen)\n" - " if _s is not None:\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_s[0],\n" - " faces=_flat(_s[1])),\n" - " color=_col, smooth_shading=True)\n" - " # Open on the lateral profile (camera along the medial-lateral\n" - " # X axis, superior up), like native MNE, instead of vtk.js's\n" - " # default anterior/face-on view. Guarded so a missing\n" - " # view_vector never costs us the render.\n" - " try:\n" - " _plotter.view_vector((-1.0, 0.0, 0.0),\n" - " viewup=(0.0, 0.0, 1.0))\n" - " except Exception:\n" - " pass\n" - " _plotter.show()\n" - " except Exception as _e:\n" - " print('[JupyterLite] pyvista-js 3D render unavailable: '\n" - " + repr(_e))\n" - " return _LiteBrain()\n" - "mne.SourceEstimate.plot = _lite_stc_plot\n" - "\n" - "# Pyodide/WASM has no OS threads, so MNE's ProgressBar background\n" - "# updater thread (used by the ProgressBar context manager, e.g. in\n" - "# permutation cluster tests) crashes with 'can't start new thread'.\n" - "# That thread only animates a cosmetic bar โ€” the computation runs on\n" - "# the main thread and __exit__ writes the final state โ€” so no-op its\n" - "# start/join. Only affects notebooks that use it; results are unchanged.\n" - "try:\n" - " from mne.utils import progressbar as _mpb\n" - " _mpb._UpdateThread.start = lambda self: None\n" - " _mpb._UpdateThread.join = lambda self, *_a, **_kw: None\n" - "except Exception:\n" - " pass\n" - "# tqdm also spawns its own monitor thread, which likewise can't start in\n" - "# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before\n" - "# any bar is created skips that thread entirely (bars still display).\n" - "try:\n" - " import tqdm as _tqdm\n" - " _tqdm.tqdm.monitor_interval = 0\n" - "except Exception:\n" - " pass\n" - "\n" - "# Switch matplotlib to inline so figures render in the notebook.\n" - "import IPython\n" - "IPython.get_ipython().run_line_magic('matplotlib', 'inline')\n" - "import matplotlib.pyplot as plt\n" - "# Silence the spurious 'FigureCanvasAgg is non-interactive' warning\n" - "# at its source. MNE's plt_show calls fig.show() (the inline backend\n" - "# isn't detected as 'agg'), and the inline Agg canvas warns. Patching\n" - "# viz.utils.plt_show is not enough: other modules did\n" - "# `from .utils import plt_show` and hold their own reference. Every\n" - "# path resolves fig.show on the class at call time, so a no-op here\n" - "# silences it everywhere. Figures still render via the inline backend.\n" - "import matplotlib.figure as _mfig\n" - "_mfig.Figure.show = lambda self, *a, **k: None\n" - "import importlib\n" - "viz_utils = importlib.import_module('mne.viz.utils')\n" - "# Also display+close via IPython for paths that call plt_show\n" - "# directly, so figures render exactly once.\n" - "def pyodide_plt_show(show=True, fig=None, **kwargs):\n" - " if not show:\n" - " return\n" - " import IPython.display\n" - " _f = fig if fig is not None else plt.gcf()\n" - " IPython.display.display(_f)\n" - " plt.close(_f)\n" - "viz_utils.plt_show = pyodide_plt_show\n" - "\n" - "# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer\n" - "# BEFORE the time-course figure, so in WASM the whole call dies and the\n" - "# notebook loses both halves. Rebuild it here: the same glass brain from\n" - "# the source space and a marker per active dipole via pyvista-js, plus\n" - "# the matplotlib time courses (which are the quantitative half). Same\n" - "# approach as the SourceEstimate.plot shim above.\n" - "def _lite_plot_sparse_source_estimates(\n" - " src, stcs, colors=None, linewidth=2, fontsize=18,\n" - " bgcolor=(0.05, 0, 0.1), opacity=0.2, brain_color=(0.7,) * 3,\n" - " show=True, high_resolution=False, fig_name=None,\n" - " fig_number=None, labels=None, modes=('cone', 'sphere'),\n" - " scale_factors=(1, 0.6), **kwargs):\n" - " import numpy as _np\n" - " from itertools import cycle as _cycle\n" - " from matplotlib.colors import to_rgb as _to_rgb\n" - " if not isinstance(stcs, list):\n" - " stcs = [stcs]\n" - " _lhp = src[0]['rr']\n" - " _pts = _np.r_[_lhp, src[1]['rr']] * 170\n" - " _nrm = _np.r_[src[0]['nn'], src[1]['nn']]\n" - " # use_tris is the decimated mesh and can be None on some source\n" - " # spaces; fall back to the full tris in that case.\n" - " _lt = src[0]['tris'] if high_resolution else src[0]['use_tris']\n" - " _rt = src[1]['tris'] if high_resolution else src[1]['use_tris']\n" - " if _lt is None or _rt is None:\n" - " _lt, _rt = src[0]['tris'], src[1]['tris']\n" - " _faces = _np.r_[_lt, len(_lhp) + _rt]\n" - " _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno]\n" - " for _s in stcs]\n" - " _uniq = _np.unique(_np.concatenate(_vertnos).ravel())\n" - " # --- time courses -------------------------------------------------\n" - " _fig = plt.figure(fig_number, layout='constrained')\n" - " _fig.clf()\n" - " _ax = _fig.add_subplot(111)\n" - " _cyc = _cycle(colors if colors is not None else\n" - " plt.rcParams['axes.prop_cycle'].by_key()['color'])\n" - " _marks = []\n" - " for _v in _uniq:\n" - " _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn]\n" - " _c = next(_cyc)\n" - " _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1))\n" - " for _k in _ind:\n" - " _m = _vertnos[_k] == _v\n" - " _ax.plot(1e3 * stcs[_k].times,\n" - " 1e9 * stcs[_k].data[_m].ravel(),\n" - " c=_c, linewidth=linewidth)\n" - " _ax.set_xlabel('Time (ms)', fontsize=fontsize)\n" - " _ax.set_ylabel('Source amplitude (nAm)', fontsize=fontsize)\n" - " if fig_name is not None:\n" - " _ax.set_title(fig_name)\n" - " pyodide_plt_show(show)\n" - " # --- glass brain + dipole markers ---------------------------------\n" - " try:\n" - " import pyvista_js as _pv\n" - " _plotter = _pv.Plotter()\n" - " _plotter.background_color = tuple(\n" - " float(min(max(_x, 0.0), 1.0)) for _x in bgcolor)\n" - " for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),\n" - " (0, -1, 0), (0, 0, 1), (0, 0, -1)):\n" - " _plotter.add_light(_pv.Light(\n" - " position=(300.0 * _lp[0], 300.0 * _lp[1],\n" - " 300.0 * _lp[2]),\n" - " focal_point=(0.0, 0.0, 0.0), intensity=0.4))\n" - " _flat_faces = _np.hstack([\n" - " _np.full((len(_faces), 1), 3, dtype=_np.int32),\n" - " _faces.astype(_np.int32)]).ravel()\n" - " _plotter.add_mesh(\n" - " _pv.PolyData(points=_pts.astype(_np.float32),\n" - " faces=_flat_faces),\n" - " color=tuple(float(_x) for _x in brain_color),\n" - " opacity=float(opacity), smooth_shading=True)\n" - " for _v, _col, _common in _marks:\n" - " _sf = float(scale_factors[1] if _common\n" - " else scale_factors[0])\n" - " _mode = modes[1] if _common else modes[0]\n" - " _xyz = tuple(float(_q) for _q in _pts[_v])\n" - " if _mode == 'sphere':\n" - " _glyph = _pv.Sphere(radius=_sf, center=_xyz)\n" - " else:\n" - " _glyph = _pv.Cone(\n" - " center=_xyz,\n" - " direction=tuple(float(_q) for _q in _nrm[_v]),\n" - " height=2.0 * _sf, radius=_sf)\n" - " _plotter.add_mesh(_glyph, color=_col, smooth_shading=True)\n" - " try:\n" - " _plotter.view_vector((-1.0, 0.0, 0.0),\n" - " viewup=(0.0, 0.0, 1.0))\n" - " except Exception:\n" - " pass\n" - " _plotter.show()\n" - " except Exception as _e:\n" - " print('[JupyterLite] pyvista-js glass brain unavailable: '\n" - " + repr(_e))\n" - "mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates\n" - "\n" - "# Each MNE plot is rendered once by pyodide_plt_show above (display()).\n" - "# When a plot call is also a cell's last expression, the method returns\n" - "# the Figure, which Jupyter echoes a SECOND time as the Out[] result\n" - "# (the duplicate seen below inline plots). Drop that redundant echo for\n" - "# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each\n" - "# plot appears exactly once. Non-figure results (numbers, DataFrames,\n" - "# reprs) are untouched, and raw matplotlib figures never shown still\n" - "# render via the inline backend's end-of-cell flush, so nothing hides.\n" - "# Wrapped in try/except (like the patches below): if anything about\n" - "# the displayhook is unexpected, silently keep the current behavior\n" - "# (harmless double render) rather than breaking the setup cell.\n" - "try:\n" - " _lite_dh = type(IPython.get_ipython().displayhook)\n" - " if not getattr(_lite_dh, '_lite_no_fig_echo', False):\n" - " _lite_dh_call = _lite_dh.__call__\n" - " def _lite_displayhook(self, result=None):\n" - " if isinstance(result, _mfig.Figure):\n" - " result = None\n" - " elif (isinstance(result, (list, tuple)) and result\n" - " and all(isinstance(_x, _mfig.Figure) for _x in result)):\n" - " result = None\n" - " return _lite_dh_call(self, result)\n" - " _lite_dh.__call__ = _lite_displayhook\n" - " _lite_dh._lite_no_fig_echo = True\n" - "except Exception:\n" - " pass\n" - "\n" - "# Real fix (not a warnings filter) for the threadpoolctl Pyodide\n" - "# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest\n" - "# release) still calls the deprecated Pyodide JsProxy.as_object_map().\n" - "# Pyodide's own message says to use as_py_json() instead; both yield the\n" - "# same library filepaths, so we swap the call at its source. This removes\n" - "# the deprecated API usage entirely, so the warning is never emitted.\n" - "# The upstream fix is already merged (joblib/threadpoolctl#201) but\n" - "# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH\n" - "# once threadpoolctl 3.7.0 is released and Pyodide bundles it.\n" - "try:\n" - " import os as _os\n" - " import threadpoolctl as _tpc\n" - " def _find_libraries_pyodide(self):\n" - " from pyodide_js._module import LDSO\n" - " for _fp in LDSO.loadedLibsByName.as_py_json():\n" - " if _os.path.exists(_fp):\n" - " self._make_controller_from_path(_fp)\n" - " _tpc.ThreadpoolController._find_libraries_pyodide = (\n" - " _find_libraries_pyodide\n" - " )\n" - "except Exception:\n" - " pass\n" + LITE_RENDERER_CELL - # Draw MNE's 3D figures with pyvista-js. Appended last so MNE is - # already imported; see doc/sphinxext/jupyterlite_lite_renderer.py. -) +_SOURCE = Path(__file__).parent / "_lite_setup_cell.py" +# Everything after the banner is what the notebook runs. The license header and +# the ruff directives above it belong to the file, not to the cell. +_BANNER = "# --- JupyterLite setup cell" + +_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) :] +_body = _body[_body.index("\n") + 1 :] + +# The renderer goes last, so MNE is already imported by the time it runs; see +# jupyterlite_lite_renderer.py. +LITE_SETUP_CELL = _body + LITE_RENDERER_CELL From 40e1626ee4408d436b3a4be07fd43f6824002d3f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 11:31:42 -0400 Subject: [PATCH 05/14] FIX: match the OSF host rather than searching the whole URL --- doc/sphinxext/_lite_setup_cell.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index ac80e878748..879126bcd77 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -188,13 +188,18 @@ def pyodide_send(self, request, **kwargs): # Block pooch from attempting large OSF downloads in the browser. # The required files are either pre-injected or unavailable. import pooch +from urllib.parse import urlparse orig_pooch_fetch = pooch.Pooch.fetch def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): url = self.get_url(fname) - if "osf.io" in url or "files.osf.io" in url: + # Compare the host rather than searching the whole URL: "osf.io" can turn + # up legitimately elsewhere in one (a query string, a path), and a + # substring test would refuse those downloads too. + host = urlparse(url).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 " From 0bb107a89594e91d21d079f439358538bc2fba52 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 19:24:07 -0400 Subject: [PATCH 06/14] MAINT: address review on the JupyterLite setup cell --- doc/sphinxext/_lite_setup_cell.py | 494 ++++-------------------- doc/sphinxext/_lite_setup_cell_3d.py | 369 ++++++++++++++++++ doc/sphinxext/jupyterlite_setup_cell.py | 36 +- 3 files changed, 465 insertions(+), 434 deletions(-) create mode 100644 doc/sphinxext/_lite_setup_cell_3d.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 879126bcd77..4aa4a063278 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -12,6 +12,9 @@ # --- JupyterLite setup cell ------------------------------------------------- # ๐Ÿ’ก This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. +# Downloading this notebook to run it locally? Delete this cell first: +# piplite exists only inside JupyterLite, and a local MNE needs none of +# the patches below. import piplite # Use piplite (not micropip) so the locally-built development MNE wheel @@ -41,45 +44,6 @@ import os import io -# lzma: try real stdlib first (Pyodide ships it); only mock if absent. The -# import has to be attempted rather than probed with find_spec, because the -# mock below is only installed when it actually fails. -try: - import lzma # noqa: F401 -except ImportError: - - class _LZMAFile: - def __init__(self, *a, **kw): - pass - - def __enter__(self): - return self - - def __exit__(self, *a): - pass - - def write(self, d): - pass - - def read(self, n=-1): - return b"" - - def close(self): - pass - - class _MockLZMA: - LZMAError = Exception - LZMAFile = _LZMAFile - FORMAT_XZ = 1 - FORMAT_ALONE = 2 - - def __getattr__(self, name): - return object - - import sys as _sys - - _sys.modules["lzma"] = _MockLZMA() - # Mock multiprocessing โ€” missing in Pyodide but imported by joblib from unittest.mock import MagicMock @@ -230,6 +194,26 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): # (not a str) since tutorials use the / operator on the result. from pathlib import Path as _Path +_mne_data_root = _Path(mne_data_path) + + +def _lite_data_path(_rel): + """Return ``_rel`` resolved under the data root, as a POSIX string.""" + return (_mne_data_root / _rel).as_posix() + + +def _lite_rel_to_data(fname): + """Return ``fname`` relative to the data root, or None if it sits outside. + + Replaces the ``startswith(mne_data_path + "/")`` plus manual slicing this + file used to repeat at every reader shim. + """ + _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() + + _sample_path = _Path(_sample_dir) @@ -248,7 +232,7 @@ def _lite_sample_data_path(*_a, **_kw): # synchronous XHR may set responseType='arraybuffer', letting a sync # data_path() read binary. def _lite_fetch_rel(_rel): - _dst = mne_data_path + "/" + _rel + _dst = _lite_data_path(_rel) if not os.path.exists(_dst): from js import XMLHttpRequest @@ -266,7 +250,7 @@ def _lite_fetch_rel(_rel): def _lite_lazy_fetch(_folder, _fname): _lite_fetch_rel(_folder + "/" + _fname) - return _Path(mne_data_path + "/" + _folder) + return _Path(_lite_data_path(_folder)) def _lite_kiloword_data_path(*_a, **_kw): @@ -296,7 +280,7 @@ def _lite_mtrf_data_path(*_a, **_kw): # individual files, so a notebook that wants the EEGLAB recording does # not also drag down the 39 MB movement raw. def _lite_testing_data_path(*_a, **_kw): - return _Path(mne_data_path + "/MNE-testing-data") + return _Path(_lite_data_path("MNE-testing-data")) mne.datasets.testing.data_path = _lite_testing_data_path @@ -307,7 +291,7 @@ def _lite_testing_data_path(*_a, **_kw): # pull them individually. def _lite_folder_data_path(_folder): def _data_path(*_a, **_kw): - return _Path(mne_data_path + "/" + _folder) + return _Path(_lite_data_path(_folder)) return _data_path @@ -349,15 +333,30 @@ def _lite_eegbci_load_data(subject, runs, *_a, **_kw): # read_inverse_operator is asked to open it. def _lite_fetch_if_under_mne_data(fname): _p = str(fname) - if _p.startswith(mne_data_path + "/"): - _lite_fetch_rel(_p[len(mne_data_path) + 1 :]) + if _lite_rel_to_data(_p) is not None: + _lite_fetch_rel(_lite_rel_to_data(_p)) return fname -# Most readers just need their file pulled down before MNE opens it. -# One wrapper, driven by the table further below; readers that need -# more than this (a sibling file, a chain of candidates) keep their -# own shim. +# Reader overrides, tier one. +# +# Nothing is on disk here. The data is served over HTTP next to the docs, +# and MNE readers validate their filename through _check_fname(must_exist= +# True) before opening it, so the file has to be in the virtual filesystem +# by the time the real reader is called. Each reader is therefore wrapped: +# fetch first at the path the caller asked for, then hand straight over to +# the original. +# +# It has to be per reader rather than one hook on mne.io.read_raw, because +# the tutorials call the specific readers (read_raw_fif, read_epochs, +# read_forward_solution, ...) directly and never go through the generic one. +# +# Most of them only need that fetch, so they are driven by the table further +# below: _mods is where the name is bound (some are exported twice, publicly +# and on a private alias), _name is the function and _arg is the keyword its +# filename arrives under when it is not passed positionally. Tier two, the +# readers needing more than a single fetch -- a .stc stem that means two +# files, a directory, a sibling -- keep their own hand-written shim below. def _lite_wrap_reader(_mods, _name, _arg): _orig = getattr(_mods[0], _name) @@ -412,10 +411,10 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): def _lite_read_raw_eeglab(input_fname, *_a, **_kw): _p = str(input_fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: for _cand in (_p, _p[:-4] + ".fdt"): try: - _lite_fetch_rel(_cand[len(mne_data_path) + 1 :]) + _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass return _orig_read_raw_eeglab(input_fname, *_a, **_kw) @@ -437,15 +436,15 @@ def _lite_fetch_dir(_rel): _lite_fetch_rel(_rel + "/" + _name) except Exception as _e: print("[JupyterLite] skipped " + _name + ": " + repr(_e)) - return mne_data_path + "/" + _rel + return _lite_data_path(_rel) def _lite_dir_reader(_orig): def _read(fname, *_a, **_kw): _p = str(fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: try: - _lite_fetch_dir(_p[len(mne_data_path) + 1 :]) + _lite_fetch_dir(_lite_rel_to_data(_p)) except Exception as _e: print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) return _orig(fname, *_a, **_kw) @@ -482,11 +481,11 @@ def _lite_read_raw_kit(input_fname, *_a, **_kw): def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): _p = str(vhdr_fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: _stem = _p[:-5] if _p.endswith(".vhdr") else _p for _cand in (_p, _stem + ".eeg", _stem + ".vmrk"): try: - _lite_fetch_rel(_cand[len(mne_data_path) + 1 :]) + _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass return _orig_read_raw_brainvision(vhdr_fname, *_a, **_kw) @@ -518,8 +517,13 @@ def _lite_load_xdf(fname, *_a, **_kw): _pyxdf.load_xdf = _lite_load_xdf except Exception: pass -# The readers that only need the fetch. Two of them are bound on a -# private alias as well as the public one, so both are listed. +# The tier-one table (see "Reader overrides" above for why this exists). +# Each row is one reader that needs nothing but its file fetched first: +# _mods where the name is bound, as a tuple because a couple of them +# are exported both publicly and on a private alias +# _name the function to wrap on each of those modules +# _arg the keyword its filename arrives under, for calls that pass it +# by name rather than positionally import mne.minimum_norm as _mne_minv import mne.chpi as _mne_chpi @@ -545,10 +549,10 @@ def _lite_load_xdf(fname, *_a, **_kw): def _lite_read_source_estimate(fname, *_a, **_kw): _p = str(fname) - if _p.startswith(mne_data_path + "/"): + if _lite_rel_to_data(_p) is not None: for _suf in ("", "-lh.stc", "-rh.stc"): try: - _lite_fetch_rel(_p[len(mne_data_path) + 1 :] + _suf) + _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) except Exception: pass return _orig_read_source_estimate(fname, *_a, **_kw) @@ -567,8 +571,8 @@ def _lite_read_source_estimate(fname, *_a, **_kw): def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) if surf in ("head-dense", "seghead"): _cands = ["bem/" + str(subject) + "-head-dense.fif", "surf/lh.seghead"] else: @@ -601,10 +605,10 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): + if subject and _lite_rel_to_data(_sd) is not None: try: _lite_fetch_rel( - _sd[len(mne_data_path) + 1 :] + _lite_rel_to_data(_sd) + "/" + str(subject) + "/bem/" @@ -638,8 +642,8 @@ def _lite_surface_head_surface( subject, source, subjects_dir, on_defects, raise_error=True ): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) _srcs = [source] if isinstance(source, str) else list(source) for _s in _srcs: try: @@ -660,8 +664,8 @@ def _lite_surface_head_surface( def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _sd.startswith(mne_data_path + "/"): - _rel = _sd[len(mne_data_path) + 1 :] + "/" + str(subject) + if subject and _lite_rel_to_data(_sd) is not None: + _rel = _lite_rel_to_data(_sd) + "/" + str(subject) _want = [ "bem/inner_skull.surf", "bem/outer_skull.surf", @@ -684,177 +688,6 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): mne.viz.plot_bem = _lite_plot_bem -# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so -# route SourceEstimate.plot() through pyvista-js (vtk.js) instead. -# pyvista-js (0.15) has no scalar colormap in its renderer, so we -# approximate MNE's Brain look with solid-colored meshes: a two-tone -# curvature base (light gyri + dark sulci) plus many thin 'hot' bands -# for the activation, on a black background with even scene lighting. -# Static, one time point, no time slider yet. Fully guarded โ€” any -# failure prints a message so the notebook completes. Returns a stub -# 'brain' whose methods (add_foci/add_text/show_view/...) are safe -# no-ops, so tutorials that call brain.add_foci(...) after plot() work. -class _LiteBrain: - def screenshot(self, *_a, **_kw): - import numpy as _np - - return _np.zeros((2, 2, 3), dtype="uint8") - - def __getattr__(self, _name): - return lambda *_a, **_kw: None - - -def _lite_stc_plot(self, *_a, **_kw): - try: - import numpy as _np - import nibabel as _nib - from scipy.spatial import cKDTree as _KDTree - from matplotlib import colormaps as _cmaps - import pyvista_js as _pv - - _subj = ( - _kw.get("subject") - or (_a[0] if _a and isinstance(_a[0], str) else None) - or "sample" - ) - _sdir = _kw.get("subjects_dir") - _sdir = ( - str(_sdir) - if _sdir is not None - else mne_data_path + "/MNE-sample-data/subjects" - ) - # surfaces are fetched relative to the served mne_data root, so - # derive that from subjects_dir rather than assuming sample -- - # a dataset may keep its FreeSurfer subjects under its own folder. - _rel_sdir = ( - _sdir[len(mne_data_path) + 1 :] - if _sdir.startswith(mne_data_path + "/") - else "MNE-sample-data/subjects" - ) - _init = _kw.get("initial_time", None) - if _init is None: - _ti = int(_np.argmax(_np.abs(self.data).mean(0))) - else: - _ti = int(_np.argmin(_np.abs(self.times - _init))) - _hot = _cmaps["hot"] - _N = 10 - - def _flat(_t): - return _np.hstack( - [_np.full((len(_t), 1), 3, dtype=_np.int64), _t.astype(_np.int64)] - ).ravel() - - def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): - _sel = _tris[_mask] - if len(_sel) == 0: - return None - _u, _iv = _np.unique(_sel, return_inverse=True) - _p = _pts[_u] - if _lift and _cen is not None: - _p = _cen + (_p - _cen) * (1.0 + _lift) - return _p, _iv.reshape(-1, 3) - - _plotter = _pv.Plotter() - _plotter.background_color = "black" - # even lighting so the surface isn't black when rotated - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _nlh = len(self.vertices[0]) - _hemis = (("lh", 0, self.vertices[0]), ("rh", 1, self.vertices[1])) - for _h, _hi, _vno in _hemis: - if len(_vno) == 0: - continue - _pre = _rel_sdir + "/" + _subj + "/surf/" + _h - _lite_fetch_rel(_pre + ".inflated") - _lite_fetch_rel(_pre + ".curv") - _bpath = _sdir + "/" + _subj + "/surf/" + _h - _rr, _tris = mne.read_surface(_bpath + ".inflated") - _cv = _nib.freesurfer.read_morph_data(_bpath + ".curv") - _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:] - # color each surface vertex from the nearest ACTIVE source - # within a small radius, so single-vertex (point) sources - # show as visible blobs and dense sources fill in as usual - _sv = _hdata[:, _ti].astype(float) - _act = _sv != 0 - _scal = _np.zeros(len(_rr)) - if _act.any(): - _atree = _KDTree(_rr[_vno][_act]) - _ad, _ai = _atree.query(_rr) - _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) - # offset hemispheres along x so they do not overlap - _off = -60.0 if _h == "lh" else 60.0 - _pts = _np.round(_rr, 2) - _pts[:, 0] = _pts[:, 0] + _off - _cen = _pts.mean(0) - # curvature base: light gyri (curv<0) + dark sulci (curv>=0) - _fc = _cv[_tris].mean(1) - for _cm, _col in ( - (_fc < 0, (0.68, 0.68, 0.68)), - (_fc >= 0, (0.38, 0.38, 0.38)), - ): - _s = _sub(_pts, _tris, _cm) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # activation as a smooth hot gradient in N value bands, - # each lifted 2% off the surface to avoid z-fighting - _fv = _scal[_tris].mean(1) - _p90 = _np.percentile(_scal, 90.0) - _fmax = float(_scal.max()) - # keep the background gray: for sparse point sources the - # 90th pct is ~0 (most of the brain is zero), which would - # paint everything, so fall back to a fraction of the max. - _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 - if _fmax > _fmin: - _edges = _np.linspace(_fmin, _fmax, _N + 1) - for _i in range(_N): - if _i < _N - 1: - _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) - else: - _m = _fv >= _edges[_i] - if int(_m.sum()) == 0: - continue - _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) - _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) - _s = _sub(_pts, _tris, _m, 0.02, _cen) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # Open on the lateral profile (camera along the medial-lateral - # X axis, superior up), like native MNE, instead of vtk.js's - # default anterior/face-on view. Guarded so a missing - # view_vector never costs us the render. - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) - return _LiteBrain() - - -mne.SourceEstimate.plot = _lite_stc_plot - # Pyodide/WASM has no OS threads, so MNE's ProgressBar background # updater thread (used by the ProgressBar context manager, e.g. in # permutation cluster tests) crashes with 'can't start new thread'. @@ -912,186 +745,3 @@ def pyodide_plt_show(show=True, fig=None, **kwargs): viz_utils.plt_show = pyodide_plt_show - - -# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer -# BEFORE the time-course figure, so in WASM the whole call dies and the -# notebook loses both halves. Rebuild it here: the same glass brain from -# the source space and a marker per active dipole via pyvista-js, plus -# the matplotlib time courses (which are the quantitative half). Same -# approach as the SourceEstimate.plot shim above. -def _lite_plot_sparse_source_estimates( - src, - stcs, - colors=None, - linewidth=2, - fontsize=18, - bgcolor=(0.05, 0, 0.1), - opacity=0.2, - brain_color=(0.7,) * 3, - show=True, - high_resolution=False, - fig_name=None, - fig_number=None, - labels=None, - modes=("cone", "sphere"), - scale_factors=(1, 0.6), - **kwargs, -): - import numpy as _np - from itertools import cycle as _cycle - from matplotlib.colors import to_rgb as _to_rgb - - if not isinstance(stcs, list): - stcs = [stcs] - _lhp = src[0]["rr"] - _pts = _np.r_[_lhp, src[1]["rr"]] * 170 - _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] - # use_tris is the decimated mesh and can be None on some source - # spaces; fall back to the full tris in that case. - _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] - _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] - if _lt is None or _rt is None: - _lt, _rt = src[0]["tris"], src[1]["tris"] - _faces = _np.r_[_lt, len(_lhp) + _rt] - _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] - _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) - # --- time courses ------------------------------------------------- - _fig = plt.figure(fig_number, layout="constrained") - _fig.clf() - _ax = _fig.add_subplot(111) - _cyc = _cycle( - colors - if colors is not None - else plt.rcParams["axes.prop_cycle"].by_key()["color"] - ) - _marks = [] - for _v in _uniq: - _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] - _c = next(_cyc) - _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) - for _k in _ind: - _m = _vertnos[_k] == _v - _ax.plot( - 1e3 * stcs[_k].times, - 1e9 * stcs[_k].data[_m].ravel(), - c=_c, - linewidth=linewidth, - ) - _ax.set_xlabel("Time (ms)", fontsize=fontsize) - _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) - if fig_name is not None: - _ax.set_title(fig_name) - pyodide_plt_show(show) - # --- glass brain + dipole markers --------------------------------- - try: - import pyvista_js as _pv - - _plotter = _pv.Plotter() - _plotter.background_color = tuple( - float(min(max(_x, 0.0), 1.0)) for _x in bgcolor - ) - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _flat_faces = _np.hstack( - [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] - ).ravel() - _plotter.add_mesh( - _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), - color=tuple(float(_x) for _x in brain_color), - opacity=float(opacity), - smooth_shading=True, - ) - for _v, _col, _common in _marks: - _sf = float(scale_factors[1] if _common else scale_factors[0]) - _mode = modes[1] if _common else modes[0] - _xyz = tuple(float(_q) for _q in _pts[_v]) - if _mode == "sphere": - _glyph = _pv.Sphere(radius=_sf, center=_xyz) - else: - _glyph = _pv.Cone( - center=_xyz, - direction=tuple(float(_q) for _q in _nrm[_v]), - height=2.0 * _sf, - radius=_sf, - ) - _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) - - -mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates - -# Each MNE plot is rendered once by pyodide_plt_show above (display()). -# When a plot call is also a cell's last expression, the method returns -# the Figure, which Jupyter echoes a SECOND time as the Out[] result -# (the duplicate seen below inline plots). Drop that redundant echo for -# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each -# plot appears exactly once. Non-figure results (numbers, DataFrames, -# reprs) are untouched, and raw matplotlib figures never shown still -# render via the inline backend's end-of-cell flush, so nothing hides. -# Wrapped in try/except (like the patches below): if anything about -# the displayhook is unexpected, silently keep the current behavior -# (harmless double render) rather than breaking the setup cell. -try: - _lite_dh = type(IPython.get_ipython().displayhook) - if not getattr(_lite_dh, "_lite_no_fig_echo", False): - _lite_dh_call = _lite_dh.__call__ - - def _lite_displayhook(self, result=None): - if isinstance(result, _mfig.Figure): - result = None - elif ( - isinstance(result, (list, tuple)) - and result - and all(isinstance(_x, _mfig.Figure) for _x in result) - ): - result = None - return _lite_dh_call(self, result) - - _lite_dh.__call__ = _lite_displayhook - _lite_dh._lite_no_fig_echo = True -except Exception: - pass - -# Real fix (not a warnings filter) for the threadpoolctl Pyodide -# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest -# release) still calls the deprecated Pyodide JsProxy.as_object_map(). -# Pyodide's own message says to use as_py_json() instead; both yield the -# same library filepaths, so we swap the call at its source. This removes -# the deprecated API usage entirely, so the warning is never emitted. -# The upstream fix is already merged (joblib/threadpoolctl#201) but -# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH -# once threadpoolctl 3.7.0 is released and Pyodide bundles it. -try: - import os as _os - import threadpoolctl as _tpc - - def _find_libraries_pyodide(self): - from pyodide_js._module import LDSO - - for _fp in LDSO.loadedLibsByName.as_py_json(): - if _os.path.exists(_fp): - self._make_controller_from_path(_fp) - - _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide -except Exception: - pass diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py new file mode 100644 index 00000000000..a10f3b71192 --- /dev/null +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -0,0 +1,369 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +# The experimental part of the browser setup, kept apart from the rest so the +# solid ground and the shifting ground are easy to tell apart. Everything here +# stands in for MNE's Brain/VTK stack, which has no WebAssembly build, and is +# the part most likely to be dropped as pyvista-js gains features upstream. +# Appended after the base cell, which it depends on: the second block below +# uses the matplotlib-inline shim that cell installs. +# This runs as a continuation of the base cell, in the same namespace, so it +# reads names that cell defined (mne, plt, the fetch helpers) rather than +# importing them again; F821 is off for that reason, not to hide typos. +# ruff: noqa: E402, F704, F821, I001 + +# --- JupyterLite setup cell, 3D ----------------------------------------------- +# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so +# route SourceEstimate.plot() through pyvista-js (vtk.js) instead. +# pyvista-js (0.15) has no scalar colormap in its renderer, so we +# approximate MNE's Brain look with solid-colored meshes: a two-tone +# curvature base (light gyri + dark sulci) plus many thin 'hot' bands +# for the activation, on a black background with even scene lighting. +# Static, one time point, no time slider yet. Fully guarded โ€” any +# failure prints a message so the notebook completes. Returns a stub +# 'brain' whose methods (add_foci/add_text/show_view/...) are safe +# no-ops, so tutorials that call brain.add_foci(...) after plot() work. +class _LiteBrain: + def screenshot(self, *_a, **_kw): + import numpy as _np + + return _np.zeros((2, 2, 3), dtype="uint8") + + def __getattr__(self, _name): + return lambda *_a, **_kw: None + + +def _lite_stc_plot(self, *_a, **_kw): + try: + import numpy as _np + import nibabel as _nib + from scipy.spatial import cKDTree as _KDTree + from matplotlib import colormaps as _cmaps + import pyvista_js as _pv + + _subj = ( + _kw.get("subject") + or (_a[0] if _a and isinstance(_a[0], str) else None) + or "sample" + ) + _sdir = _kw.get("subjects_dir") + _sdir = ( + str(_sdir) + if _sdir is not None + else _lite_data_path("MNE-sample-data/subjects") + ) + # surfaces are fetched relative to the served mne_data root, so + # derive that from subjects_dir rather than assuming sample -- + # a dataset may keep its FreeSurfer subjects under its own folder. + _rel_sdir = ( + _lite_rel_to_data(_sdir) + if _lite_rel_to_data(_sdir) is not None + else "MNE-sample-data/subjects" + ) + _init = _kw.get("initial_time", None) + if _init is None: + _ti = int(_np.argmax(_np.abs(self.data).mean(0))) + else: + _ti = int(_np.argmin(_np.abs(self.times - _init))) + _hot = _cmaps["hot"] + _N = 10 + + def _flat(_t): + return _np.hstack( + [_np.full((len(_t), 1), 3, dtype=_np.int64), _t.astype(_np.int64)] + ).ravel() + + def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): + _sel = _tris[_mask] + if len(_sel) == 0: + return None + _u, _iv = _np.unique(_sel, return_inverse=True) + _p = _pts[_u] + if _lift and _cen is not None: + _p = _cen + (_p - _cen) * (1.0 + _lift) + return _p, _iv.reshape(-1, 3) + + _plotter = _pv.Plotter() + _plotter.background_color = "black" + # even lighting so the surface isn't black when rotated + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _nlh = len(self.vertices[0]) + _hemis = (("lh", 0, self.vertices[0]), ("rh", 1, self.vertices[1])) + for _h, _hi, _vno in _hemis: + if len(_vno) == 0: + continue + _pre = _rel_sdir + "/" + _subj + "/surf/" + _h + _lite_fetch_rel(_pre + ".inflated") + _lite_fetch_rel(_pre + ".curv") + _bpath = _sdir + "/" + _subj + "/surf/" + _h + _rr, _tris = mne.read_surface(_bpath + ".inflated") + _cv = _nib.freesurfer.read_morph_data(_bpath + ".curv") + _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:] + # color each surface vertex from the nearest ACTIVE source + # within a small radius, so single-vertex (point) sources + # show as visible blobs and dense sources fill in as usual + _sv = _hdata[:, _ti].astype(float) + _act = _sv != 0 + _scal = _np.zeros(len(_rr)) + if _act.any(): + _atree = _KDTree(_rr[_vno][_act]) + _ad, _ai = _atree.query(_rr) + _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) + # offset hemispheres along x so they do not overlap + _off = -60.0 if _h == "lh" else 60.0 + _pts = _np.round(_rr, 2) + _pts[:, 0] = _pts[:, 0] + _off + _cen = _pts.mean(0) + # curvature base: light gyri (curv<0) + dark sulci (curv>=0) + _fc = _cv[_tris].mean(1) + for _cm, _col in ( + (_fc < 0, (0.68, 0.68, 0.68)), + (_fc >= 0, (0.38, 0.38, 0.38)), + ): + _s = _sub(_pts, _tris, _cm) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # activation as a smooth hot gradient in N value bands, + # each lifted 2% off the surface to avoid z-fighting + _fv = _scal[_tris].mean(1) + _p90 = _np.percentile(_scal, 90.0) + _fmax = float(_scal.max()) + # keep the background gray: for sparse point sources the + # 90th pct is ~0 (most of the brain is zero), which would + # paint everything, so fall back to a fraction of the max. + _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + if _fmax > _fmin: + _edges = _np.linspace(_fmin, _fmax, _N + 1) + for _i in range(_N): + if _i < _N - 1: + _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) + else: + _m = _fv >= _edges[_i] + if int(_m.sum()) == 0: + continue + _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) + _s = _sub(_pts, _tris, _m, 0.02, _cen) + if _s is not None: + _plotter.add_mesh( + _pv.PolyData(points=_s[0], faces=_flat(_s[1])), + color=_col, + smooth_shading=True, + ) + # Open on the lateral profile (camera along the medial-lateral + # X axis, superior up), like native MNE, instead of vtk.js's + # default anterior/face-on view. Guarded so a missing + # view_vector never costs us the render. + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) + return _LiteBrain() + + +mne.SourceEstimate.plot = _lite_stc_plot + + +# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer +# BEFORE the time-course figure, so in WASM the whole call dies and the +# notebook loses both halves. Rebuild it here: the same glass brain from +# the source space and a marker per active dipole via pyvista-js, plus +# the matplotlib time courses (which are the quantitative half). Same +# approach as the SourceEstimate.plot shim above. +def _lite_plot_sparse_source_estimates( + src, + stcs, + colors=None, + linewidth=2, + fontsize=18, + bgcolor=(0.05, 0, 0.1), + opacity=0.2, + brain_color=(0.7,) * 3, + show=True, + high_resolution=False, + fig_name=None, + fig_number=None, + labels=None, + modes=("cone", "sphere"), + scale_factors=(1, 0.6), + **kwargs, +): + import numpy as _np + from itertools import cycle as _cycle + from matplotlib.colors import to_rgb as _to_rgb + + if not isinstance(stcs, list): + stcs = [stcs] + _lhp = src[0]["rr"] + _pts = _np.r_[_lhp, src[1]["rr"]] * 170 + _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] + # use_tris is the decimated mesh and can be None on some source + # spaces; fall back to the full tris in that case. + _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] + _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] + if _lt is None or _rt is None: + _lt, _rt = src[0]["tris"], src[1]["tris"] + _faces = _np.r_[_lt, len(_lhp) + _rt] + _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] + _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) + # --- time courses ------------------------------------------------- + _fig = plt.figure(fig_number, layout="constrained") + _fig.clf() + _ax = _fig.add_subplot(111) + _cyc = _cycle( + colors + if colors is not None + else plt.rcParams["axes.prop_cycle"].by_key()["color"] + ) + _marks = [] + for _v in _uniq: + _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] + _c = next(_cyc) + _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) + for _k in _ind: + _m = _vertnos[_k] == _v + _ax.plot( + 1e3 * stcs[_k].times, + 1e9 * stcs[_k].data[_m].ravel(), + c=_c, + linewidth=linewidth, + ) + _ax.set_xlabel("Time (ms)", fontsize=fontsize) + _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) + if fig_name is not None: + _ax.set_title(fig_name) + pyodide_plt_show(show) + # --- glass brain + dipole markers --------------------------------- + try: + import pyvista_js as _pv + + _plotter = _pv.Plotter() + _plotter.background_color = tuple( + float(min(max(_x, 0.0), 1.0)) for _x in bgcolor + ) + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + _plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + _flat_faces = _np.hstack( + [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] + ).ravel() + _plotter.add_mesh( + _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), + color=tuple(float(_x) for _x in brain_color), + opacity=float(opacity), + smooth_shading=True, + ) + for _v, _col, _common in _marks: + _sf = float(scale_factors[1] if _common else scale_factors[0]) + _mode = modes[1] if _common else modes[0] + _xyz = tuple(float(_q) for _q in _pts[_v]) + if _mode == "sphere": + _glyph = _pv.Sphere(radius=_sf, center=_xyz) + else: + _glyph = _pv.Cone( + center=_xyz, + direction=tuple(float(_q) for _q in _nrm[_v]), + height=2.0 * _sf, + radius=_sf, + ) + _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) + try: + _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + _plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) + + +mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates + +# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# When a plot call is also a cell's last expression, the method returns +# the Figure, which Jupyter echoes a SECOND time as the Out[] result +# (the duplicate seen below inline plots). Drop that redundant echo for +# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each +# plot appears exactly once. Non-figure results (numbers, DataFrames, +# reprs) are untouched, and raw matplotlib figures never shown still +# render via the inline backend's end-of-cell flush, so nothing hides. +# Wrapped in try/except (like the patches below): if anything about +# the displayhook is unexpected, silently keep the current behavior +# (harmless double render) rather than breaking the setup cell. +try: + _lite_dh = type(IPython.get_ipython().displayhook) + if not getattr(_lite_dh, "_lite_no_fig_echo", False): + _lite_dh_call = _lite_dh.__call__ + + def _lite_displayhook(self, result=None): + if isinstance(result, _mfig.Figure): + result = None + elif ( + isinstance(result, (list, tuple)) + and result + and all(isinstance(_x, _mfig.Figure) for _x in result) + ): + result = None + return _lite_dh_call(self, result) + + _lite_dh.__call__ = _lite_displayhook + _lite_dh._lite_no_fig_echo = True +except Exception: + pass + +# Real fix (not a warnings filter) for the threadpoolctl Pyodide +# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest +# release) still calls the deprecated Pyodide JsProxy.as_object_map(). +# Pyodide's own message says to use as_py_json() instead; both yield the +# same library filepaths, so we swap the call at its source. This removes +# the deprecated API usage entirely, so the warning is never emitted. +# The upstream fix is already merged (joblib/threadpoolctl#201) but +# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH +# once threadpoolctl 3.7.0 is released and Pyodide bundles it. +try: + import os as _os + import threadpoolctl as _tpc + + def _find_libraries_pyodide(self): + from pyodide_js._module import LDSO + + for _fp in LDSO.loadedLibsByName.as_py_json(): + if _os.path.exists(_fp): + self._make_controller_from_path(_fp) + + _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 33cfa89ecae..df51882e052 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -2,15 +2,22 @@ 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 itself lives in ``_lite_setup_cell.py`` as -ordinary Python, so ruff lints and formats it; this module only reads that file -and exposes it as the string the browser kernel needs. +disk, and the 3D renderer. The cell lives in ``_lite_setup_cell.py`` and +``_lite_setup_cell_3d.py`` as ordinary Python, so ruff lints and formats it; +this module only reads those files and joins them into the string the browser +kernel needs. The 3D half is kept separate because it stands in for MNE's +Brain/VTK stack and is the part most likely to change as pyvista-js gains +features upstream. 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. @@ -21,17 +28,22 @@ from jupyterlite_lite_renderer import LITE_RENDERER_CELL -_SOURCE = Path(__file__).parent / "_lite_setup_cell.py" # Everything after the banner is what the notebook runs. The license header and # the ruff directives above it belong to the file, not to the cell. _BANNER = "# --- JupyterLite setup cell" -_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) :] -_body = _body[_body.index("\n") + 1 :] -# The renderer goes last, so MNE is already imported by the time it runs; see -# jupyterlite_lite_renderer.py. -LITE_SETUP_CELL = _body + LITE_RENDERER_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 :] + + +# Order matters: the 3D half reads the matplotlib shim the base half installs, +# and the renderer goes last so MNE is already imported by the time it runs. +LITE_SETUP_CELL = ( + _read("_lite_setup_cell.py") + _read("_lite_setup_cell_3d.py") + LITE_RENDERER_CELL +) From 2231fd1108dbc5bf6d69276a8ba5232eaa3cee09 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 06:49:55 -0400 Subject: [PATCH 07/14] MAINT: address review on the JupyterLite setup cell Drops the underscore from the module aliases, uses args/kwargs and pathlib, and collapses two reader-table entries that were setting the attribute on the same module twice. --- doc/sphinxext/_lite_setup_cell.py | 293 ++++++++++++++------------- doc/sphinxext/_lite_setup_cell_3d.py | 25 +-- 2 files changed, 166 insertions(+), 152 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 4aa4a063278..c6d0abb94f2 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -9,6 +9,12 @@ # it as a real file instead of a string. # ruff: noqa: E402, F704, I001 +# Naming: everything this cell defines lands in the notebook's own namespace, +# so anything it invents is _-prefixed and cannot shadow a variable the +# tutorial goes on to use. Module imports are left plain: a tutorial importing +# the same module binds the same object, so there is nothing to protect. +# `mne_data_path` is the deliberate exception, since a reader may want it. + # --- JupyterLite setup cell ------------------------------------------------- # ๐Ÿ’ก This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. @@ -54,15 +60,18 @@ sys.modules["multiprocessing.util"] = m.util sys.modules["multiprocessing.pool"] = m.pool -# Patch requests so pooch can fetch files already on /drive/mne_data. -# open_url works for both text and binary in Pyodide >= 0.21. +# Route requests through pyodide.http so the downloads that still go through +# pooch work in the browser. The one that matters is fetch_infant_template +# (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader +# -> requests, and whose files live on github.com rather than OSF. open_url +# handles both text and binary in Pyodide >= 0.21. import requests import pyodide -orig_send = requests.Session.send +_orig_send = requests.Session.send -def pyodide_send(self, request, **kwargs): +def _pyodide_send(self, request, **kwargs): try: buf = pyodide.http.open_url(request.url) content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() @@ -70,7 +79,7 @@ def pyodide_send(self, request, **kwargs): content = content.encode("utf-8") except Exception as e: print(f"open_url failed for {request.url}: {e}") - return orig_send(self, request, **kwargs) + return _orig_send(self, request, **kwargs) response = requests.Response() response.status_code = 200 response.url = request.url @@ -78,7 +87,7 @@ def pyodide_send(self, request, **kwargs): return response -requests.Session.send = pyodide_send +requests.Session.send = _pyodide_send # /drive/ in Pyodide requires Cross-Origin-Isolation headers # (COOP/COEP) which many static servers (e.g. CircleCI artifacts) @@ -88,13 +97,13 @@ def pyodide_send(self, request, **kwargs): # Pyodide may run in a web worker (no `window`); `location` exists # in both the main thread and workers, so use it to find the docs # root by splitting on '/lite/'. -import pyodide.http as _phttp -import js as _js +import pyodide.http +import js try: - _page = str(_js.location.href) + _page = str(js.location.href) except Exception: - _page = str(_js.window.location.href) + _page = str(js.window.location.href) _base = _page.split("/lite/")[0] + "/mne_data/" mne_data_path = "/tmp/mne_data" _sample_dir = mne_data_path + "/MNE-sample-data" @@ -132,7 +141,7 @@ def pyodide_send(self, request, **kwargs): continue _url = _base + "MNE-sample-data/" + _f try: - _r = await _phttp.pyfetch(_url) + _r = await pyodide.http.pyfetch(_url) if _r.status != 200: print(f" HTTP {_r.status} for {_url}") continue @@ -149,15 +158,17 @@ def pyodide_send(self, request, **kwargs): os.environ["MNE_DATA"] = mne_data_path os.environ["MNE_DATASETS_SAMPLE_PATH"] = mne_data_path -# Block pooch from attempting large OSF downloads in the browser. -# The required files are either pre-injected or unavailable. +# Turn an OSF download into a readable error rather than an opaque CORS or +# out-of-memory failure. This covers Pooch.fetch, which is the path +# mne/datasets/_fetch.py uses for every packaged dataset; the handful of +# callers that use pooch.retrieve directly all point at other hosts. import pooch from urllib.parse import urlparse -orig_pooch_fetch = pooch.Pooch.fetch +_orig_pooch_fetch = pooch.Pooch.fetch -def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): +def _pyodide_pooch_fetch(self, fname, processor=None, downloader=None): url = self.get_url(fname) # Compare the host rather than searching the whole URL: "osf.io" can turn # up legitimately elsewhere in one (a query string, a path), and a @@ -170,10 +181,10 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): "dataset downloads. Open this notebook from mne.tools " "where sample data is pre-bundled, or run it locally." ) - return orig_pooch_fetch(self, fname, processor=processor, downloader=downloader) + return _orig_pooch_fetch(self, fname, processor=processor, downloader=downloader) -pooch.Pooch.fetch = pyodide_pooch_fetch +pooch.Pooch.fetch = _pyodide_pooch_fetch # Import MNE and finalize setup. import mne @@ -185,21 +196,22 @@ def pyodide_pooch_fetch(self, fname, processor=None, downloader=None): with open(_cfg, "w") as _f: _f.write("{}") mne.set_config("MNE_DATA", mne_data_path) -for ds in ["SAMPLE", "TESTING", "SSVEP", "EEGBCI", "SOMATO", "BRAINSTORM"]: - mne.set_config(f"MNE_DATASETS_{ds}_PATH", mne_data_path) +for _ds in ["SAMPLE", "TESTING", "SSVEP", "EEGBCI", "SOMATO", "BRAINSTORM"]: + mne.set_config(f"MNE_DATASETS_{_ds}_PATH", mne_data_path) +del _ds # Bypass pooch's archive check: data_path() normally looks for the # .tar.gz archive, not just the extracted folder. Return the folder # directly so pooch never tries to download from OSF. Return a Path # (not a str) since tutorials use the / operator on the result. -from pathlib import Path as _Path +from pathlib import Path -_mne_data_root = _Path(mne_data_path) +_mne_data_root = Path(mne_data_path) -def _lite_data_path(_rel): - """Return ``_rel`` resolved under the data root, as a POSIX string.""" - return (_mne_data_root / _rel).as_posix() +def _lite_data_path(rel): + """Return ``rel`` resolved under the data root.""" + return _mne_data_root / rel def _lite_rel_to_data(fname): @@ -208,16 +220,16 @@ def _lite_rel_to_data(fname): Replaces the ``startswith(mne_data_path + "/")`` plus manual slicing this file used to repeat at every reader shim. """ - _p = _Path(str(fname)) + _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() -_sample_path = _Path(_sample_dir) +_sample_path = Path(_sample_dir) -def _lite_sample_data_path(*_a, **_kw): +def _lite_sample_data_path(*args, **kwargs): return _sample_path @@ -231,36 +243,35 @@ def _lite_sample_data_path(*_a, **_kw): # notebook's setup. Pyodide runs in a web worker here, where a # synchronous XHR may set responseType='arraybuffer', letting a sync # data_path() read binary. -def _lite_fetch_rel(_rel): - _dst = _lite_data_path(_rel) - if not os.path.exists(_dst): +def _lite_fetch_rel(rel): + _dst = _lite_data_path(rel) + if not _dst.exists(): from js import XMLHttpRequest _xhr = XMLHttpRequest.new() - _xhr.open("GET", _base + _rel, False) + _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})") - os.makedirs(os.path.dirname(_dst), exist_ok=True) - with open(_dst, "wb") as _fh: - _fh.write(bytes(_xhr.response.to_py())) + 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_lazy_fetch(_folder, _fname): _lite_fetch_rel(_folder + "/" + _fname) - return _Path(_lite_data_path(_folder)) + return _lite_data_path(_folder) -def _lite_kiloword_data_path(*_a, **_kw): +def _lite_kiloword_data_path(*args, **kwargs): return _lite_lazy_fetch("MNE-kiloword-data", "kword_metadata-epo.fif") mne.datasets.kiloword.data_path = _lite_kiloword_data_path -def _lite_erp_core_data_path(*_a, **_kw): +def _lite_erp_core_data_path(*args, **kwargs): return _lite_lazy_fetch( "MNE-ERP-CORE-data", "ERP-CORE_Subject-001_Task-Flankers_eeg.fif" ) @@ -269,7 +280,7 @@ def _lite_erp_core_data_path(*_a, **_kw): mne.datasets.erp_core.data_path = _lite_erp_core_data_path -def _lite_mtrf_data_path(*_a, **_kw): +def _lite_mtrf_data_path(*args, **kwargs): return _lite_lazy_fetch("mTRF_1.5", "speech_data.mat") @@ -279,8 +290,8 @@ def _lite_mtrf_data_path(*_a, **_kw): # testing hands back the folder and lets the shimmed readers pull # individual files, so a notebook that wants the EEGLAB recording does # not also drag down the 39 MB movement raw. -def _lite_testing_data_path(*_a, **_kw): - return _Path(_lite_data_path("MNE-testing-data")) +def _lite_testing_data_path(*args, **kwargs): + return _lite_data_path("MNE-testing-data") mne.datasets.testing.data_path = _lite_testing_data_path @@ -290,8 +301,8 @@ def _lite_testing_data_path(*_a, **_kw): # files those examples read are served, and the shimmed readers below # pull them individually. def _lite_folder_data_path(_folder): - def _data_path(*_a, **_kw): - return _Path(_lite_data_path(_folder)) + def _data_path(*args, **kwargs): + return _lite_data_path(_folder) return _data_path @@ -308,7 +319,7 @@ def _data_path(*_a, **_kw): getattr(mne.datasets, _ds).data_path = _lite_folder_data_path(_folder) -def _lite_eegbci_load_data(subject, runs, *_a, **_kw): +def _lite_eegbci_load_data(subject, runs, *args, **kwargs): _runs = [runs] if isinstance(runs, (int, float)) else list(runs) _subjects = list(subject) if isinstance(subject, (list, tuple)) else [subject] _out = [] @@ -318,7 +329,7 @@ def _lite_eegbci_load_data(subject, runs, *_a, **_kw): "MNE-eegbci-data/files/eegmmidb/1.0.0/" f"S{int(_s):03d}/S{int(_s):03d}R{int(_r):02d}.edf" ) - _out.append(_Path(_lite_fetch_rel(_rel))) + _out.append(_lite_fetch_rel(_rel)) return _out @@ -338,38 +349,44 @@ def _lite_fetch_if_under_mne_data(fname): return fname -# Reader overrides, tier one. +# Reader overrides. +# +# Nothing is on disk here. The data is served over HTTP next to the docs, so +# a file has to be in the virtual filesystem by the time a reader opens it. # -# Nothing is on disk here. The data is served over HTTP next to the docs, -# and MNE readers validate their filename through _check_fname(must_exist= -# True) before opening it, so the file has to be in the virtual filesystem -# by the time the real reader is called. Each reader is therefore wrapped: -# fetch first at the path the caller asked for, then hand straight over to -# the original. +# There IS one general hook: nearly every MNE reader validates its filename +# through _check_fname(must_exist=True) first, so patching that one function +# (further down) covers read_info, read_evokeds, read_cov, read_label and the +# rest with no wrapper each. Three kinds of caller escape it, and those are +# what the wrappers below are for: # -# It has to be per reader rather than one hook on mne.io.read_raw, because -# the tutorials call the specific readers (read_raw_fif, read_epochs, -# read_forward_solution, ...) directly and never go through the generic one. +# 1. one filename that means several files. read_raw_brainvision is handed +# only the .vhdr, opens it, reads the names of its .eeg and .vmrk out of +# it, and opens those -- by which point we are inside the reader and it +# is too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem +# (lh + rh) and the formats that are a directory rather than a file. +# 2. code that probes instead of opening. _get_head_surface calls +# os.path.exists before any reader runs, so a fetch-on-open hook never +# fires for it. +# 3. readers that open their file without validating it first. # -# Most of them only need that fetch, so they are driven by the table further -# below: _mods is where the name is bound (some are exported twice, publicly -# and on a private alias), _name is the function and _arg is the keyword its -# filename arrives under when it is not passed positionally. Tier two, the -# readers needing more than a single fetch -- a .stc stem that means two -# files, a directory, a sibling -- keep their own hand-written shim below. -def _lite_wrap_reader(_mods, _name, _arg): - _orig = getattr(_mods[0], _name) - - def _wrapped(*_a, **_kw): - if _a: - _a = (_lite_fetch_if_under_mne_data(_a[0]),) + _a[1:] - elif _arg in _kw: +# The ones in group 3 need nothing but the fetch, so they are driven by the +# table further down rather than a shim each. +# +# `module` is where the name is bound, `name` is the function and `arg` is the +# keyword its filename arrives under when it is not passed positionally. +def _lite_wrap_reader(module, name, arg): + orig = getattr(module, name) + + def wrapped(*args, **kwargs): + if args: + args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] + elif arg in kwargs: # positionally, as the hand-written shims did - _a = (_lite_fetch_if_under_mne_data(_kw.pop(_arg)),) - return _orig(*_a, **_kw) + args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) + return orig(*args, **kwargs) - for _m in _mods: - setattr(_m, _name, _wrapped) + setattr(module, name, wrapped) # Lazily fetch the heavy sample raw / source-space files only when a @@ -380,21 +397,21 @@ def _wrapped(*_a, **_kw): # function covers read_info, read_evokeds, read_cov, read_label and the # rest without a wrapper each. Failures stay silent here so MNE still # raises its own, clearer error for a file that genuinely is missing. -import mne.utils.check as _mne_check +import mne.utils.check as mne_check -_orig_check_fname = _mne_check._check_fname +_orig_check_fname = mne_check._check_fname -def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): +def _lite_check_fname(fname, overwrite=False, must_exist=False, *args, **kwargs): if must_exist: try: _lite_fetch_if_under_mne_data(fname) except Exception: pass - return _orig_check_fname(fname, overwrite, must_exist, *_a, **_kw) + return _orig_check_fname(fname, overwrite, must_exist, *args, **kwargs) -_mne_check._check_fname = _lite_check_fname +mne_check._check_fname = _lite_check_fname # modules that imported it before now hold their own reference; ones # loaded later (mne lazy-loads most of itself) pick up the patch for _m in list(sys.modules.values()): @@ -403,13 +420,14 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *_a, **_kw): and getattr(_m, "_check_fname", None) is _orig_check_fname ): _m._check_fname = _lite_check_fname -# read_label, read_epochs and read_raw_edf open their file directly -# rather than validating it first, so the hook above never sees them -# an EEGLAB .set keeps its samples in a sibling .fdt, so fetch both +# Below are the readers the _check_fname hook cannot serve on its own, +# because one filename implies more than one file. +# +# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. _orig_read_raw_eeglab = mne.io.read_raw_eeglab -def _lite_read_raw_eeglab(input_fname, *_a, **_kw): +def _lite_read_raw_eeglab(input_fname, *args, **kwargs): _p = str(input_fname) if _lite_rel_to_data(_p) is not None: for _cand in (_p, _p[:-4] + ".fdt"): @@ -417,7 +435,7 @@ def _lite_read_raw_eeglab(input_fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass - return _orig_read_raw_eeglab(input_fname, *_a, **_kw) + return _orig_read_raw_eeglab(input_fname, *args, **kwargs) mne.io.read_raw_eeglab = _lite_read_raw_eeglab @@ -440,14 +458,14 @@ def _lite_fetch_dir(_rel): def _lite_dir_reader(_orig): - def _read(fname, *_a, **_kw): + def _read(fname, *args, **kwargs): _p = str(fname) if _lite_rel_to_data(_p) is not None: try: _lite_fetch_dir(_lite_rel_to_data(_p)) except Exception as _e: print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) - return _orig(fname, *_a, **_kw) + return _orig(fname, *args, **kwargs) return _read @@ -457,21 +475,21 @@ def _read(fname, *_a, **_kw): # the logging tutorial reads a KIT file from inside the installed # package; the wheel excludes mne/**/tests, so stage the served copy # into the path the tutorial builds rather than editing the tutorial -import shutil as _shutil +import shutil _orig_read_raw_kit = mne.io.read_raw_kit -def _lite_read_raw_kit(input_fname, *_a, **_kw): +def _lite_read_raw_kit(input_fname, *args, **kwargs): _p = str(input_fname) if _p.endswith("test.sqd") and not os.path.exists(_p): try: _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") os.makedirs(os.path.dirname(_p), exist_ok=True) - _shutil.copyfile(_staged, _p) + shutil.copyfile(_staged, _p) except Exception as _e: print("[JupyterLite] could not stage test.sqd: " + repr(_e)) - return _orig_read_raw_kit(input_fname, *_a, **_kw) + return _orig_read_raw_kit(input_fname, *args, **kwargs) mne.io.read_raw_kit = _lite_read_raw_kit @@ -479,7 +497,7 @@ def _lite_read_raw_kit(input_fname, *_a, **_kw): _orig_read_raw_brainvision = mne.io.read_raw_brainvision -def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): +def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): _p = str(vhdr_fname) if _lite_rel_to_data(_p) is not None: _stem = _p[:-5] if _p.endswith(".vhdr") else _p @@ -488,7 +506,7 @@ def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_cand)) except Exception: pass - return _orig_read_raw_brainvision(vhdr_fname, *_a, **_kw) + return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) mne.io.read_raw_brainvision = _lite_read_raw_brainvision @@ -496,58 +514,54 @@ def _lite_read_raw_brainvision(vhdr_fname, *_a, **_kw): # the heatmap example draws its stimulus straight through pyplot, and # read_xdf goes through pyxdf -- neither is an MNE reader, so shim the # two entry points as well -import matplotlib.pyplot as _plt +import matplotlib.pyplot as plt -_orig_imread = _plt.imread +_orig_imread = plt.imread -def _lite_imread(fname, *_a, **_kw): - return _orig_imread(_lite_fetch_if_under_mne_data(fname), *_a, **_kw) +def _lite_imread(fname, *args, **kwargs): + return _orig_imread(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) -_plt.imread = _lite_imread +plt.imread = _lite_imread try: import pyxdf as _pyxdf _orig_load_xdf = _pyxdf.load_xdf - def _lite_load_xdf(fname, *_a, **_kw): - return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *_a, **_kw) + def _lite_load_xdf(fname, *args, **kwargs): + return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) _pyxdf.load_xdf = _lite_load_xdf except Exception: pass # The tier-one table (see "Reader overrides" above for why this exists). # Each row is one reader that needs nothing but its file fetched first: -# _mods where the name is bound, as a tuple because a couple of them -# are exported both publicly and on a private alias -# _name the function to wrap on each of those modules -# _arg the keyword its filename arrives under, for calls that pass it -# by name rather than positionally -import mne.minimum_norm as _mne_minv -import mne.chpi as _mne_chpi - -for _mods, _name, _arg in ( - ((mne,), "read_forward_solution", "fname"), - ((_mne_minv, mne.minimum_norm), "read_inverse_operator", "fname"), - ((mne.io,), "read_raw_fif", "fname"), - ((mne.io,), "read_raw", "fname"), - ((mne,), "read_source_spaces", "fname"), - ((mne,), "read_label", "filename"), - ((mne,), "read_epochs", "fname"), - ((mne.io,), "read_raw_edf", "input_fname"), - ((mne,), "read_bem_solution", "fname"), - ((mne,), "read_events", "fname"), - ((mne.io,), "read_raw_eyelink", "fname"), - ((_mne_chpi, mne.chpi), "read_head_pos", "fname"), +# module where the name is bound +# name the function to wrap there +# arg the keyword its filename arrives under, for calls that pass it +# by name rather than positionally +for _module, _name, _arg in ( + (mne, "read_forward_solution", "fname"), + (mne.minimum_norm, "read_inverse_operator", "fname"), + (mne.io, "read_raw_fif", "fname"), + (mne.io, "read_raw", "fname"), + (mne, "read_source_spaces", "fname"), + (mne, "read_label", "filename"), + (mne, "read_epochs", "fname"), + (mne.io, "read_raw_edf", "input_fname"), + (mne, "read_bem_solution", "fname"), + (mne, "read_events", "fname"), + (mne.io, "read_raw_eyelink", "fname"), + (mne.chpi, "read_head_pos", "fname"), ): - _lite_wrap_reader(_mods, _name, _arg) + _lite_wrap_reader(_module, _name, _arg) # read_source_estimate is handed the stem of a .stc pair, so fetch # both hemispheres before letting MNE resolve the name itself. _orig_read_source_estimate = mne.read_source_estimate -def _lite_read_source_estimate(fname, *_a, **_kw): +def _lite_read_source_estimate(fname, *args, **kwargs): _p = str(fname) if _lite_rel_to_data(_p) is not None: for _suf in ("", "-lh.stc", "-rh.stc"): @@ -555,7 +569,7 @@ def _lite_read_source_estimate(fname, *_a, **_kw): _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) except Exception: pass - return _orig_read_source_estimate(fname, *_a, **_kw) + return _orig_read_source_estimate(fname, *args, **kwargs) mne.read_source_estimate = _lite_read_source_estimate @@ -564,9 +578,9 @@ def _lite_read_source_estimate(fname, *_a, **_kw): # fires. Fetch the candidates first and let MNE choose as it normally # would. Several viz modules bind the name at import time, so rebind # it wherever the original landed instead of in one known place. -import mne._freesurfer as _mne_fs +import mne._freesurfer as mne_fs -_orig_get_head_surface = _mne_fs._get_head_surface +_orig_get_head_surface = mne_fs._get_head_surface def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): @@ -587,7 +601,7 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): return _orig_get_head_surface(surf, subject, subjects_dir, bem=bem, verbose=verbose) -_mne_fs._get_head_surface = _lite_get_head_surface +mne_fs._get_head_surface = _lite_get_head_surface # import the 3D module first so the sweep below is guaranteed to see # it; anything imported later picks the patched name up on its own. import mne.viz._3d # noqa: F401 @@ -600,7 +614,7 @@ def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): _m._get_head_surface = _lite_get_head_surface # same story for the skull surfaces, which _check_fname insists # already exist on disk -_orig_get_skull_surface = _mne_fs._get_skull_surface +_orig_get_skull_surface = mne_fs._get_skull_surface def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): @@ -622,7 +636,7 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) ) -_mne_fs._get_skull_surface = _lite_get_skull_surface +mne_fs._get_skull_surface = _lite_get_skull_surface for _m in list(sys.modules.values()): if ( getattr(_m, "__name__", "").startswith("mne") @@ -633,9 +647,9 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) # one in mne/surface.py: it takes a list of candidate sources and # probes bem/ with os.path.exists and glob, raising if the directory # is absent, so the candidates have to land before it runs. -import mne.surface as _mne_surface +import mne.surface as mne_surface -_orig_surface_head = _mne_surface._get_head_surface +_orig_surface_head = mne_surface._get_head_surface def _lite_surface_head_surface( @@ -655,14 +669,14 @@ def _lite_surface_head_surface( ) -_mne_surface._get_head_surface = _lite_surface_head_surface +mne_surface._get_head_surface = _lite_surface_head_surface # plot_bem globs bem/*.surf and requires the bem directory to exist, # so pull its three contours (plus the MRI it draws them on) down # first; fetching creates the directory as a side effect. _orig_plot_bem = mne.viz.plot_bem -def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): +def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): _sd = str(subjects_dir) if subjects_dir is not None else "" if subject and _lite_rel_to_data(_sd) is not None: _rel = _lite_rel_to_data(_sd) + "/" + str(subject) @@ -670,9 +684,9 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): "bem/inner_skull.surf", "bem/outer_skull.surf", "bem/outer_skin.surf", - "mri/" + str(_kw.get("mri", "T1.mgz")), + "mri/" + str(kwargs.get("mri", "T1.mgz")), ] - _bs = _kw.get("brain_surfaces") + _bs = kwargs.get("brain_surfaces") if _bs is not None: _bs = [_bs] if isinstance(_bs, str) else list(_bs) for _b in _bs: @@ -682,7 +696,7 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): _lite_fetch_rel(_rel + "/" + _c) except Exception: pass - return _orig_plot_bem(subject, subjects_dir, *_a, **_kw) + return _orig_plot_bem(subject, subjects_dir, *args, **kwargs) mne.viz.plot_bem = _lite_plot_bem @@ -698,7 +712,7 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): from mne.utils import progressbar as _mpb _mpb._UpdateThread.start = lambda self: None - _mpb._UpdateThread.join = lambda self, *_a, **_kw: None + _mpb._UpdateThread.join = lambda self, *args, **kwargs: None except Exception: pass # tqdm also spawns its own monitor thread, which likewise can't start in @@ -715,7 +729,6 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): import IPython IPython.get_ipython().run_line_magic("matplotlib", "inline") -import matplotlib.pyplot as plt # Silence the spurious 'FigureCanvasAgg is non-interactive' warning # at its source. MNE's plt_show calls fig.show() (the inline backend @@ -724,17 +737,17 @@ def _lite_plot_bem(subject=None, subjects_dir=None, *_a, **_kw): # `from .utils import plt_show` and hold their own reference. Every # path resolves fig.show on the class at call time, so a no-op here # silences it everywhere. Figures still render via the inline backend. -import matplotlib.figure as _mfig +import matplotlib.figure as mpl_figure -_mfig.Figure.show = lambda self, *a, **k: None +mpl_figure.Figure.show = lambda self, *a, **k: None import importlib -viz_utils = importlib.import_module("mne.viz.utils") +_viz_utils = importlib.import_module("mne.viz.utils") # Also display+close via IPython for paths that call plt_show # directly, so figures render exactly once. -def pyodide_plt_show(show=True, fig=None, **kwargs): +def _pyodide_plt_show(show=True, fig=None, **kwargs): if not show: return import IPython.display @@ -744,4 +757,4 @@ def pyodide_plt_show(show=True, fig=None, **kwargs): plt.close(_f) -viz_utils.plt_show = pyodide_plt_show +_viz_utils.plt_show = _pyodide_plt_show diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py index a10f3b71192..a548d596f59 100644 --- a/doc/sphinxext/_lite_setup_cell_3d.py +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -25,16 +25,16 @@ # 'brain' whose methods (add_foci/add_text/show_view/...) are safe # no-ops, so tutorials that call brain.add_foci(...) after plot() work. class _LiteBrain: - def screenshot(self, *_a, **_kw): + def screenshot(self, *args, **kwargs): import numpy as _np return _np.zeros((2, 2, 3), dtype="uint8") def __getattr__(self, _name): - return lambda *_a, **_kw: None + return lambda *args, **kwargs: None -def _lite_stc_plot(self, *_a, **_kw): +def _lite_stc_plot(self, *args, **kwargs): try: import numpy as _np import nibabel as _nib @@ -43,15 +43,16 @@ def _lite_stc_plot(self, *_a, **_kw): import pyvista_js as _pv _subj = ( - _kw.get("subject") - or (_a[0] if _a and isinstance(_a[0], str) else None) + kwargs.get("subject") + or (args[0] if args and isinstance(args[0], str) else None) or "sample" ) - _sdir = _kw.get("subjects_dir") + _sdir = kwargs.get("subjects_dir") + # kept as a str on both branches: it is concatenated below _sdir = ( str(_sdir) if _sdir is not None - else _lite_data_path("MNE-sample-data/subjects") + else str(_lite_data_path("MNE-sample-data/subjects")) ) # surfaces are fetched relative to the served mne_data root, so # derive that from subjects_dir rather than assuming sample -- @@ -61,7 +62,7 @@ def _lite_stc_plot(self, *_a, **_kw): if _lite_rel_to_data(_sdir) is not None else "MNE-sample-data/subjects" ) - _init = _kw.get("initial_time", None) + _init = kwargs.get("initial_time", None) if _init is None: _ti = int(_np.argmax(_np.abs(self.data).mean(0))) else: @@ -254,7 +255,7 @@ def _lite_plot_sparse_source_estimates( _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) if fig_name is not None: _ax.set_title(fig_name) - pyodide_plt_show(show) + _pyodide_plt_show(show) # --- glass brain + dipole markers --------------------------------- try: import pyvista_js as _pv @@ -312,7 +313,7 @@ def _lite_plot_sparse_source_estimates( mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates -# Each MNE plot is rendered once by pyodide_plt_show above (display()). +# Each MNE plot is rendered once by _pyodide_plt_show above (display()). # When a plot call is also a cell's last expression, the method returns # the Figure, which Jupyter echoes a SECOND time as the Out[] result # (the duplicate seen below inline plots). Drop that redundant echo for @@ -329,12 +330,12 @@ def _lite_plot_sparse_source_estimates( _lite_dh_call = _lite_dh.__call__ def _lite_displayhook(self, result=None): - if isinstance(result, _mfig.Figure): + if isinstance(result, mpl_figure.Figure): result = None elif ( isinstance(result, (list, tuple)) and result - and all(isinstance(_x, _mfig.Figure) for _x in result) + and all(isinstance(_x, mpl_figure.Figure) for _x in result) ): result = None return _lite_dh_call(self, result) From a2b3ae408c3d9d77e0d1a27d8846f02d4c85a63a Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 12:27:02 -0400 Subject: [PATCH 08/14] MAINT: reorder the setup cell and fix two reader shims Group the fetch helpers together and split the reader overrides into the three kinds of treatment they need, per review. Along the way: read_events takes `filename`, not `fname`, and eegbci takes `subjects`, which 35_eeg_no_mri passes by keyword. The eager fetch now raises instead of continuing past a file the build did not stage. --- doc/sphinxext/_lite_setup_cell.py | 725 ++++++++++++------------ doc/sphinxext/jupyterlite_setup_cell.py | 5 +- 2 files changed, 364 insertions(+), 366 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index c6d0abb94f2..404f8cceee9 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -15,12 +15,24 @@ # the same module binds the same object, so there is nothing to protect. # `mne_data_path` is the deliberate exception, since a reader may want it. +# Layout, in the order the sections appear below: +# 1. install MNE into the browser kernel +# 2. patch what Pyodide lacks, before MNE is imported +# 3. work out where the data is served and copy the core of it in +# 4. import MNE and point its datasets at that copy +# 5. define the fetch helpers everything after this point uses +# 6. tell MNE where each dataset lives +# 7. wrap the readers, in three groups by how much work each needs +# 8. stub out what WebAssembly cannot do + # --- JupyterLite setup cell ------------------------------------------------- # ๐Ÿ’ก This cell is automatically added to the start of each notebook. # It installs MNE and patches the browser environment for Pyodide. # Downloading this notebook to run it locally? Delete this cell first: # piplite exists only inside JupyterLite, and a local MNE needs none of # the patches below. + +# === 1. Install ============================================================== import piplite # Use piplite (not micropip) so the locally-built development MNE wheel @@ -46,9 +58,12 @@ keep_going=True, ) +# === 2. Pyodide compatibility, before MNE is imported ======================== import sys import os import io +import inspect +from pathlib import Path # Mock multiprocessing โ€” missing in Pyodide but imported by joblib from unittest.mock import MagicMock @@ -89,10 +104,11 @@ def _pyodide_send(self, request, **kwargs): requests.Session.send = _pyodide_send +# === 3. Where the data comes from =========================================== # /drive/ in Pyodide requires Cross-Origin-Isolation headers # (COOP/COEP) which many static servers (e.g. CircleCI artifacts) -# do not send. Fetch the data over HTTP into /tmp/mne_data instead -# โ€” same-origin, no CORS. The data is served at the docs root +# do not send. Fetch the data over HTTP into /tmp/mne_data instead: +# same-origin, no CORS. The data is served at the docs root # (/mne_data/...) via Sphinx html_extra_path. # Pyodide may run in a web worker (no `window`); `location` exists # in both the main thread and workers, so use it to find the docs @@ -106,7 +122,8 @@ def _pyodide_send(self, request, **kwargs): _page = str(js.window.location.href) _base = _page.split("/lite/")[0] + "/mne_data/" mne_data_path = "/tmp/mne_data" -_sample_dir = mne_data_path + "/MNE-sample-data" +_mne_data_root = Path(mne_data_path) +_sample_dir = _mne_data_root / "MNE-sample-data" # Eager 'core': small, commonly-used sample files fetched once at # notebook start. The heavy files (raw / filt raw / ernoise / fwd / # inv / src, ~360 MB total) are intentionally omitted here -- they are @@ -134,27 +151,45 @@ def _pyodide_send(self, request, **kwargs): "SSS/sss_cal_mgh.dat", "SSS/ct_sparse_mgh.fif", ] +# These are served from the same origin as this page, so if the page loaded, +# the server is up: a miss here means the docs build did not stage the file, +# not that the network is flaky. Several of them (the SSS calibration pair, +# the surfaces read through nibabel) have no lazy path either, so a miss would +# otherwise surface as a confusing error many cells later. Collect every +# failure and raise once, naming them all, since one staging bug usually drops +# more than one file. +# print, not a logger: this cell runs in the browser kernel, not in the Sphinx +# process, so its output is simply what the notebook reader sees. print("Fetching MNE sample data (once per session)...") +_missing = [] for _f in _sample_files: - _dst = _sample_dir + "/" + _f - if os.path.exists(_dst): + _dst = _sample_dir / _f + if _dst.exists(): continue _url = _base + "MNE-sample-data/" + _f try: _r = await pyodide.http.pyfetch(_url) if _r.status != 200: - print(f" HTTP {_r.status} for {_url}") + _missing.append(f"{_f} (HTTP {_r.status})") continue _d = await _r.bytes() + # a static server answers a missing path with its 404 page and a 200 + # status, so the body is the only way to tell the two apart if _d[:4] == b"//`` (group B).""" + _rel = _lite_rel_to_data(subjects_dir if subjects_dir is not None else "") + if not subject or _rel is None: + return + _lite_fetch_optional(f"{_rel}/{subject}/{_p}" for _p in rel_paths) -def _lite_mtrf_data_path(*args, **kwargs): - return _lite_lazy_fetch("mTRF_1.5", "speech_data.mat") +def _lite_dataset_path(folder, probe=None): + """Build a ``data_path()`` that returns ``folder`` under the data root. + With ``probe``, the named file is fetched when data_path() is called. That + is what covers mtrf, whose .mat is read by scipy rather than by an MNE + reader, so nothing downstream would otherwise fetch it. + """ -mne.datasets.mtrf.data_path = _lite_mtrf_data_path + def _data_path(*args, **kwargs): + if probe is not None: + _lite_fetch_rel(folder + "/" + probe) + return _lite_data_path(folder) + return _data_path -# testing hands back the folder and lets the shimmed readers pull -# individual files, so a notebook that wants the EEGLAB recording does -# not also drag down the 39 MB movement raw. -def _lite_testing_data_path(*args, **kwargs): - return _lite_data_path("MNE-testing-data") +def _lite_wrap_reader(module, name): + """Wrap ``module.name`` so its filename argument is fetched before it opens. -mne.datasets.testing.data_path = _lite_testing_data_path + The keyword to intercept is read off the wrapped function rather than + listed by hand: the readers below disagree about whether it is ``fname``, + ``filename`` or ``input_fname``, and a name written out here that drifted + from the real one would silently stop fetching for keyword callers. + """ + orig = getattr(module, name) + arg = next(iter(inspect.signature(orig).parameters)) + def wrapped(*args, **kwargs): + if args: + args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] + elif arg in kwargs: + # move it to a positional argument, since it is no longer in kwargs + args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) + return orig(*args, **kwargs) -# Same again for the datasets behind a single example each. Only the -# files those examples read are served, and the shimmed readers below -# pull them individually. -def _lite_folder_data_path(_folder): - def _data_path(*args, **kwargs): - return _lite_data_path(_folder) + setattr(module, name, wrapped) - return _data_path +def _lite_dir_reader(orig): + """Wrap a reader that is handed a folder rather than a file.""" -for _ds, _folder in ( - ("ssvep", "ssvep-example-data"), - ("misc", "MNE-misc-data"), - ("eyelink", "MNE-eyelink-data"), - ("fnirs_motor", "MNE-fNIRS-motor-data"), - ("refmeg_noise", "MNE-refmeg-noise-data"), - ("phantom_kernel", "MNE-phantom-kernel-data"), - ("multimodal", "MNE-multimodal-data"), + def _read(fname, *args, **kwargs): + _rel = _lite_rel_to_data(fname) + if _rel is not None: + try: + _lite_fetch_dir(_rel) + 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 module that already imported ``old`` at ``new``. + + MNE lazy-loads most of itself, so a module that ran ``from x import f`` + before this cell holds its own reference and would not see the patch. + Modules imported afterwards pick it up on their own. + """ + for _m in list(sys.modules.values()): + if ( + getattr(_m, "__name__", "").startswith("mne") + and getattr(_m, name, None) is old + ): + setattr(_m, name, new) + + +# === 6. Where MNE looks for each dataset ==================================== +# data_path() normally checks for the .tar.gz archive, not just the extracted +# folder, and would try to download from OSF when it does not find one. Point +# each dataset at its folder under the data root instead. The ones with a probe +# file are used by only a couple of notebooks each, so nothing is fetched until +# their data_path() is actually called. +for _ds, _folder, _probe in ( + ("sample", "MNE-sample-data", None), + # testing hands back the folder and lets the shimmed readers pull + # individual files, so a notebook that wants the EEGLAB recording does + # not also drag down the 39 MB movement raw. + ("testing", "MNE-testing-data", None), + # datasets behind a single example each; only the files those examples + # read are served, and the readers below pull them individually + ("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/erp_core for Epochs 30 & 40, mtrf for the decoding examples + ("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_folder_data_path(_folder) + getattr(mne.datasets, _ds).data_path = _lite_dataset_path(_folder, _probe) +del _ds, _folder, _probe -def _lite_eegbci_load_data(subject, runs, *args, **kwargs): +# eegbci is addressed by subject and run rather than by path, so it needs its +# own shim rather than a row in the table above. +def _lite_eegbci_load_data(subjects, runs, *args, **kwargs): + # the parameter is `subjects`, matching MNE: 35_eeg_no_mri calls it by + # keyword, so a shim spelled `subject` would raise TypeError there _runs = [runs] if isinstance(runs, (int, float)) else list(runs) - _subjects = list(subject) if isinstance(subject, (list, tuple)) else [subject] + _subjects = list(subjects) if isinstance(subjects, (list, tuple)) else [subjects] _out = [] for _s in _subjects: for _r in _runs: @@ -335,68 +459,27 @@ def _lite_eegbci_load_data(subject, runs, *args, **kwargs): mne.datasets.eegbci.load_data = _lite_eegbci_load_data - -# Some MNE-sample-data files (e.g. the fixed-orientation forward/ -# inverse used by the point-spread tutorial) aren't in the eager -# _sample_files list above because only one or two notebooks need -# them. Rather than hand-listing every such file, lazily fetch any -# sample-data path the first time read_forward_solution/ -# read_inverse_operator is asked to open it. -def _lite_fetch_if_under_mne_data(fname): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - _lite_fetch_rel(_lite_rel_to_data(_p)) - return fname - - -# Reader overrides. +# === 7. Reader overrides ==================================================== +# MNE functions need one of three treatments here, depending on how much they +# do before the file is actually opened. # -# Nothing is on disk here. The data is served over HTTP next to the docs, so -# a file has to be in the virtual filesystem by the time a reader opens it. -# -# There IS one general hook: nearly every MNE reader validates its filename -# through _check_fname(must_exist=True) first, so patching that one function -# (further down) covers read_info, read_evokeds, read_cov, read_label and the -# rest with no wrapper each. Three kinds of caller escape it, and those are -# what the wrappers below are for: -# -# 1. one filename that means several files. read_raw_brainvision is handed +# A. reads one file, and validates the name first. Nearly every MNE reader +# calls _check_fname(must_exist=True) before opening anything, so patching +# that single function covers read_info, read_evokeds, read_cov, +# read_label and the rest at once. A handful skip the validation, and are +# listed in a table instead. Nothing else is needed for this group. +# B. probes the filesystem before any reader runs. _get_head_surface calls +# os.path.exists, plot_bem globs bem/*.surf, so a fetch-on-open hook never +# fires. The candidates have to be on disk before the probe. +# C. one filename that means several files. read_raw_brainvision is handed # only the .vhdr, opens it, reads the names of its .eeg and .vmrk out of -# it, and opens those -- by which point we are inside the reader and it -# is too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem -# (lh + rh) and the formats that are a directory rather than a file. -# 2. code that probes instead of opening. _get_head_surface calls -# os.path.exists before any reader runs, so a fetch-on-open hook never -# fires for it. -# 3. readers that open their file without validating it first. -# -# The ones in group 3 need nothing but the fetch, so they are driven by the -# table further down rather than a shim each. -# -# `module` is where the name is bound, `name` is the function and `arg` is the -# keyword its filename arrives under when it is not passed positionally. -def _lite_wrap_reader(module, name, arg): - orig = getattr(module, name) - - def wrapped(*args, **kwargs): - if args: - args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] - elif arg in kwargs: - # positionally, as the hand-written shims did - args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) - return orig(*args, **kwargs) +# it, and opens those, by which point we are inside the reader and it is +# too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem +# (lh + rh), and the formats that are a directory rather than a file. - setattr(module, name, wrapped) - - -# Lazily fetch the heavy sample raw / source-space files only when a -# notebook actually reads them (same pattern as the fwd/inv shims -# above), instead of pulling the whole sample set up front. -# Nearly every MNE reader validates its filename through -# _check_fname(must_exist=True) before opening it, so hooking that one -# function covers read_info, read_evokeds, read_cov, read_label and the -# rest without a wrapper each. Failures stay silent here so MNE still -# raises its own, clearer error for a file that genuinely is missing. +# --- A. reads one file ------------------------------------------------------ +# The general hook. Failures stay silent here so MNE still raises its own, +# clearer error for a file that genuinely is missing. import mne.utils.check as mne_check _orig_check_fname = mne_check._check_fname @@ -412,108 +495,28 @@ def _lite_check_fname(fname, overwrite=False, must_exist=False, *args, **kwargs) mne_check._check_fname = _lite_check_fname -# modules that imported it before now hold their own reference; ones -# loaded later (mne lazy-loads most of itself) pick up the patch -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_check_fname", None) is _orig_check_fname - ): - _m._check_fname = _lite_check_fname -# Below are the readers the _check_fname hook cannot serve on its own, -# because one filename implies more than one file. -# -# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. -_orig_read_raw_eeglab = mne.io.read_raw_eeglab - - -def _lite_read_raw_eeglab(input_fname, *args, **kwargs): - _p = str(input_fname) - if _lite_rel_to_data(_p) is not None: - for _cand in (_p, _p[:-4] + ".fdt"): - try: - _lite_fetch_rel(_lite_rel_to_data(_cand)) - except Exception: - pass - return _orig_read_raw_eeglab(input_fname, *args, **kwargs) - - -mne.io.read_raw_eeglab = _lite_read_raw_eeglab - - -# read_raw_nirx and read_raw_egi open a folder, so there is no single -# name to fetch; conf.py leaves a listing next to the copy. -def _lite_fetch_dir(_rel): - _manifest = _lite_fetch_rel(_rel + "/_lite_manifest.txt") - with open(_manifest) as _fh: - _names = [_n.strip() for _n in _fh if _n.strip()] - for _name in _names: - # one unreachable member must not abandon the rest of the - # recording; the reader complains if it needed that file - try: - _lite_fetch_rel(_rel + "/" + _name) - except Exception as _e: - print("[JupyterLite] skipped " + _name + ": " + repr(_e)) - return _lite_data_path(_rel) - - -def _lite_dir_reader(_orig): - def _read(fname, *args, **kwargs): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - try: - _lite_fetch_dir(_lite_rel_to_data(_p)) - except Exception as _e: - print("[JupyterLite] could not fetch " + _p + ": " + repr(_e)) - return _orig(fname, *args, **kwargs) - - return _read - - -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) -# the logging tutorial reads a KIT file from inside the installed -# package; the wheel excludes mne/**/tests, so stage the served copy -# into the path the tutorial builds rather than editing the tutorial -import shutil - -_orig_read_raw_kit = mne.io.read_raw_kit - - -def _lite_read_raw_kit(input_fname, *args, **kwargs): - _p = str(input_fname) - if _p.endswith("test.sqd") and not os.path.exists(_p): - try: - _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") - os.makedirs(os.path.dirname(_p), exist_ok=True) - shutil.copyfile(_staged, _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 -# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk -_orig_read_raw_brainvision = mne.io.read_raw_brainvision - - -def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): - _p = str(vhdr_fname) - if _lite_rel_to_data(_p) is not None: - _stem = _p[:-5] if _p.endswith(".vhdr") else _p - for _cand in (_p, _stem + ".eeg", _stem + ".vmrk"): - try: - _lite_fetch_rel(_lite_rel_to_data(_cand)) - except Exception: - pass - return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) - - -mne.io.read_raw_brainvision = _lite_read_raw_brainvision -# eyelink .asc recordings are single files -# the heatmap example draws its stimulus straight through pyplot, and -# read_xdf goes through pyxdf -- neither is an MNE reader, so shim the -# two entry points as well +_lite_rebind("_check_fname", _orig_check_fname, _lite_check_fname) +# The readers that open their file without validating it first, so the hook +# above never sees them. Each needs nothing but its file fetched. +for _module, _name in ( + (mne, "read_forward_solution"), + (mne.minimum_norm, "read_inverse_operator"), + (mne.io, "read_raw_fif"), + (mne.io, "read_raw"), + (mne, "read_source_spaces"), + (mne, "read_label"), + (mne, "read_epochs"), + (mne.io, "read_raw_edf"), + (mne, "read_bem_solution"), + (mne, "read_events"), + (mne.io, "read_raw_eyelink"), + (mne.chpi, "read_head_pos"), +): + _lite_wrap_reader(_module, _name) +del _module, _name +# The eyetracking heatmap example draws its stimulus straight through pyplot, +# and read_xdf goes through pyxdf. Neither is an MNE reader, but both take a +# path we serve, so they get the same treatment. import matplotlib.pyplot as plt _orig_imread = plt.imread @@ -524,125 +527,61 @@ def _lite_imread(fname, *args, **kwargs): plt.imread = _lite_imread +# guarded: pyxdf has no pure-Python wheel on every Pyodide build, and only the +# XDF example needs it try: - import pyxdf as _pyxdf + import pyxdf - _orig_load_xdf = _pyxdf.load_xdf + _orig_load_xdf = pyxdf.load_xdf def _lite_load_xdf(fname, *args, **kwargs): return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) - _pyxdf.load_xdf = _lite_load_xdf + pyxdf.load_xdf = _lite_load_xdf except Exception: pass -# The tier-one table (see "Reader overrides" above for why this exists). -# Each row is one reader that needs nothing but its file fetched first: -# module where the name is bound -# name the function to wrap there -# arg the keyword its filename arrives under, for calls that pass it -# by name rather than positionally -for _module, _name, _arg in ( - (mne, "read_forward_solution", "fname"), - (mne.minimum_norm, "read_inverse_operator", "fname"), - (mne.io, "read_raw_fif", "fname"), - (mne.io, "read_raw", "fname"), - (mne, "read_source_spaces", "fname"), - (mne, "read_label", "filename"), - (mne, "read_epochs", "fname"), - (mne.io, "read_raw_edf", "input_fname"), - (mne, "read_bem_solution", "fname"), - (mne, "read_events", "fname"), - (mne.io, "read_raw_eyelink", "fname"), - (mne.chpi, "read_head_pos", "fname"), -): - _lite_wrap_reader(_module, _name, _arg) -# read_source_estimate is handed the stem of a .stc pair, so fetch -# both hemispheres before letting MNE resolve the name itself. -_orig_read_source_estimate = mne.read_source_estimate - - -def _lite_read_source_estimate(fname, *args, **kwargs): - _p = str(fname) - if _lite_rel_to_data(_p) is not None: - for _suf in ("", "-lh.stc", "-rh.stc"): - try: - _lite_fetch_rel(_lite_rel_to_data(_p) + _suf) - except Exception: - pass - return _orig_read_source_estimate(fname, *args, **kwargs) - -mne.read_source_estimate = _lite_read_source_estimate -# plot_alignment locates its head surface by probing the filesystem -# with os.path.exists before any reader runs, so a reader shim never -# fires. Fetch the candidates first and let MNE choose as it normally -# would. Several viz modules bind the name at import time, so rebind -# it wherever the original landed instead of in one known place. +# --- B. probes the filesystem first ----------------------------------------- +# plot_alignment locates its head surface with os.path.exists before any reader +# runs. Fetch the candidates first and let MNE choose as it normally would. +# Several viz modules bind the name at import time, so rebind it wherever the +# original landed rather than in one known place. import mne._freesurfer as mne_fs _orig_get_head_surface = mne_fs._get_head_surface def _lite_get_head_surface(surf, subject, subjects_dir, bem=None, verbose=None): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - if surf in ("head-dense", "seghead"): - _cands = ["bem/" + str(subject) + "-head-dense.fif", "surf/lh.seghead"] - else: - # same order MNE tries, so the browser picks the same - # surface the rendered docs did - _cands = ["bem/outer_skin.surf", "bem/" + str(subject) + "-head.fif"] - for _c in _cands: - try: - _lite_fetch_rel(_rel + "/" + _c) - except Exception: - pass + if surf in ("head-dense", "seghead"): + _cands = [f"bem/{subject}-head-dense.fif", "surf/lh.seghead"] + else: + # same order MNE tries, so the browser picks the same + # surface the rendered docs did + _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) mne_fs._get_head_surface = _lite_get_head_surface -# import the 3D module first so the sweep below is guaranteed to see -# it; anything imported later picks the patched name up on its own. +# import the 3D module first so the rebind is guaranteed to see it; +# anything imported later picks the patched name up on its own. import mne.viz._3d # noqa: F401 -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_get_head_surface", None) is _orig_get_head_surface - ): - _m._get_head_surface = _lite_get_head_surface +_lite_rebind("_get_head_surface", _orig_get_head_surface, _lite_get_head_surface) # same story for the skull surfaces, which _check_fname insists # already exist on disk _orig_get_skull_surface = mne_fs._get_skull_surface def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - try: - _lite_fetch_rel( - _lite_rel_to_data(_sd) - + "/" - + str(subject) - + "/bem/" - + surf - + "_skull.surf" - ) - except Exception: - pass + _lite_fetch_candidates(subject, subjects_dir, [f"bem/{surf}_skull.surf"]) return _orig_get_skull_surface( surf, subject, subjects_dir, bem=bem, verbose=verbose ) mne_fs._get_skull_surface = _lite_get_skull_surface -for _m in list(sys.modules.values()): - if ( - getattr(_m, "__name__", "").startswith("mne") - and getattr(_m, "_get_skull_surface", None) is _orig_get_skull_surface - ): - _m._get_skull_surface = _lite_get_skull_surface +_lite_rebind("_get_skull_surface", _orig_get_skull_surface, _lite_get_skull_surface) # dig_mri_distances reaches a second, unrelated _get_head_surface, the # one in mne/surface.py: it takes a list of candidate sources and # probes bem/ with os.path.exists and glob, raising if the directory @@ -655,20 +594,17 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) def _lite_surface_head_surface( subject, source, subjects_dir, on_defects, raise_error=True ): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - _srcs = [source] if isinstance(source, str) else list(source) - for _s in _srcs: - try: - _lite_fetch_rel(_rel + "/bem/" + str(subject) + "-" + _s + ".fif") - except Exception: - pass + _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 ) +# no _lite_rebind for this one: unlike the _freesurfer function above, nothing +# outside mne/surface.py imports it by name, so patching the module is enough. mne_surface._get_head_surface = _lite_surface_head_surface # plot_bem globs bem/*.surf and requires the bem directory to exist, # so pull its three contours (plus the MRI it draws them on) down @@ -677,51 +613,112 @@ def _lite_surface_head_surface( def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): - _sd = str(subjects_dir) if subjects_dir is not None else "" - if subject and _lite_rel_to_data(_sd) is not None: - _rel = _lite_rel_to_data(_sd) + "/" + str(subject) - _want = [ - "bem/inner_skull.surf", - "bem/outer_skull.surf", - "bem/outer_skin.surf", - "mri/" + str(kwargs.get("mri", "T1.mgz")), - ] - _bs = kwargs.get("brain_surfaces") - if _bs is not None: - _bs = [_bs] if isinstance(_bs, str) else list(_bs) - for _b in _bs: - _want += ["surf/lh." + _b, "surf/rh." + _b] - for _c in _want: - try: - _lite_fetch_rel(_rel + "/" + _c) - except Exception: - pass + _want = [ + "bem/inner_skull.surf", + "bem/outer_skull.surf", + "bem/outer_skin.surf", + "mri/" + str(kwargs.get("mri", "T1.mgz")), + ] + _bs = kwargs.get("brain_surfaces") + if _bs is not None: + _bs = [_bs] if isinstance(_bs, str) else list(_bs) + for _b in _bs: + _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.viz.plot_bem = _lite_plot_bem +# --- C. one filename, several files ----------------------------------------- +# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. +_orig_read_raw_eeglab = mne.io.read_raw_eeglab + + +def _lite_read_raw_eeglab(input_fname, *args, **kwargs): + _rel = _lite_rel_to_data(input_fname) + if _rel is not None: + _lite_fetch_optional((_rel, _rel[:-4] + ".fdt")) + return _orig_read_raw_eeglab(input_fname, *args, **kwargs) + + +mne.io.read_raw_eeglab = _lite_read_raw_eeglab +# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk +_orig_read_raw_brainvision = mne.io.read_raw_brainvision + + +def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): + _rel = _lite_rel_to_data(vhdr_fname) + if _rel is not None: + _stem = _rel[:-5] if _rel.endswith(".vhdr") else _rel + _lite_fetch_optional((_rel, _stem + ".eeg", _stem + ".vmrk")) + return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) + + +mne.io.read_raw_brainvision = _lite_read_raw_brainvision +# read_source_estimate is handed the stem of a .stc pair, so fetch +# both hemispheres before letting MNE resolve the name itself. +_orig_read_source_estimate = mne.read_source_estimate + + +def _lite_read_source_estimate(fname, *args, **kwargs): + _rel = _lite_rel_to_data(fname) + if _rel is not None: + _lite_fetch_optional(_rel + _suf for _suf in ("", "-lh.stc", "-rh.stc")) + return _orig_read_source_estimate(fname, *args, **kwargs) + + +mne.read_source_estimate = _lite_read_source_estimate +# read_raw_nirx and read_raw_egi open a folder, listed by its manifest +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) +# the odd one out in this group: the name is enough, but it points inside the +# installed package rather than at the data root. The logging tutorial builds +# a path into mne/**/tests, which the wheel excludes, so copy the served file +# to where the tutorial expects it rather than editing the tutorial. +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: + _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") + _p.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(_staged, _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 +# === 8. What WebAssembly cannot do ========================================== # Pyodide/WASM has no OS threads, so MNE's ProgressBar background # updater thread (used by the ProgressBar context manager, e.g. in # permutation cluster tests) crashes with 'can't start new thread'. -# That thread only animates a cosmetic bar โ€” the computation runs on -# the main thread and __exit__ writes the final state โ€” so no-op its +# That thread only animates a cosmetic bar: the computation runs on +# the main thread and __exit__ writes the final state, so no-op its # start/join. Only affects notebooks that use it; results are unchanged. +# Guarded because this is a private MNE path: if it is ever renamed, losing a +# cosmetic patch is better than failing every notebook at the setup cell. try: - from mne.utils import progressbar as _mpb + from mne.utils import progressbar - _mpb._UpdateThread.start = lambda self: None - _mpb._UpdateThread.join = lambda self, *args, **kwargs: None + progressbar._UpdateThread.start = lambda self: None + progressbar._UpdateThread.join = lambda self, *args, **kwargs: None except Exception: pass # tqdm also spawns its own monitor thread, which likewise can't start in # WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before # any bar is created skips that thread entirely (bars still display). +# Guarded because tqdm is a transitive dependency that may not be installed. try: - import tqdm as _tqdm + import tqdm - _tqdm.tqdm.monitor_interval = 0 + tqdm.tqdm.monitor_interval = 0 except Exception: pass diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index df51882e052..298347ce5aa 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -28,8 +28,9 @@ from jupyterlite_lite_renderer import LITE_RENDERER_CELL -# Everything after the banner is what the notebook runs. The license header and -# the ruff directives above it belong to the file, not to the cell. +# Each source file read below is split at this banner: everything after it is +# what the notebook runs, and what sits above it in that file (license header, +# ruff directives, notes for whoever edits it) stays behind. _BANNER = "# --- JupyterLite setup cell" From df7abccd55527d54856eb9427d0af83a4dd6dd30 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 13:32:57 -0400 Subject: [PATCH 09/14] MAINT: stop the 3D stub faking a screenshot brain.screenshot() returned a blank 2x2 image, so 10_publication_figure cropped it and published two black squares as the real before/after. It raises now. Also: the requests shim reports the real HTTP status instead of always 200, so pooch fails on a 404 rather than on a later hash mismatch, and the plt_show comment no longer blames a bug MNE fixed in gh-14076. --- doc/sphinxext/_lite_setup_cell.py | 64 +++++++++++----------- doc/sphinxext/_lite_setup_cell_3d.py | 81 ++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 47 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 404f8cceee9..60af172d23a 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -37,8 +37,8 @@ # Use piplite (not micropip) so the locally-built development MNE wheel # bundled into the JupyterLite build is preferred over the older PyPI -# release; -# piplite checks the local index first and falls back to PyPI for deps. +# release: piplite checks the local index first and falls back to PyPI +# for dependencies. # keep_going=True so a dependency with no pure-Python wheel is reported # at the end rather than aborting the whole install on the first one. await piplite.install( @@ -69,36 +69,40 @@ from unittest.mock import MagicMock if "multiprocessing" not in sys.modules: - m = MagicMock() - m.cpu_count.return_value = 1 - sys.modules["multiprocessing"] = m - sys.modules["multiprocessing.util"] = m.util - sys.modules["multiprocessing.pool"] = m.pool - -# Route requests through pyodide.http so the downloads that still go through -# pooch work in the browser. The one that matters is fetch_infant_template + _mp = MagicMock() + _mp.cpu_count.return_value = 1 + sys.modules["multiprocessing"] = _mp + sys.modules["multiprocessing.util"] = _mp.util + sys.modules["multiprocessing.pool"] = _mp.pool + +# Route requests over the browser's own transport so the downloads that still +# go through pooch work here. The one that matters is fetch_infant_template # (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader -# -> requests, and whose files live on github.com rather than OSF. open_url -# handles both text and binary in Pyodide >= 0.21. +# -> requests, and whose files live on github.com rather than OSF. +# +# XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call +# _lite_fetch_rel uses below: open_url reports no status, so a 404 page came +# back looking like a successful 200 and pooch wrote the error page to disk, +# only failing later on a confusing hash mismatch. XHR gives the real status, +# which is what pooch's raise_for_status() needs. Nothing is caught here: the +# browser is the only transport available, so a failure has no fallback worth +# taking and the real error is more useful than a substituted one. import requests -import pyodide _orig_send = requests.Session.send def _pyodide_send(self, request, **kwargs): - try: - buf = pyodide.http.open_url(request.url) - content = buf.getvalue() if hasattr(buf, "getvalue") else buf.read() - if isinstance(content, str): - content = content.encode("utf-8") - except Exception as e: - print(f"open_url failed for {request.url}: {e}") - return _orig_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 = 200 + response.status_code = _xhr.status response.url = request.url - response.raw = io.BytesIO(content) + response.raw = io.BytesIO(bytes(_xhr.response.to_py())) return response @@ -727,13 +731,13 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): IPython.get_ipython().run_line_magic("matplotlib", "inline") -# Silence the spurious 'FigureCanvasAgg is non-interactive' warning -# at its source. MNE's plt_show calls fig.show() (the inline backend -# isn't detected as 'agg'), and the inline Agg canvas warns. Patching -# viz.utils.plt_show is not enough: other modules did -# `from .utils import plt_show` and hold their own reference. Every -# path resolves fig.show on the class at call time, so a no-op here -# silences it everywhere. Figures still render via the inline backend. +# Silence the spurious 'FigureCanvasAgg is non-interactive' warning that the +# inline Agg canvas raises from fig.show(). MNE's own plt_show no longer +# triggers it (gh-14076 taught it to call plt.show() on inline backends), but +# tutorials still call fig.show() directly -- 50_ssvep does it four times, and +# 10_background_stats once -- and those warn. Every path resolves fig.show on +# the class at call time, so a no-op here covers them all. Figures still +# render via the inline backend. import matplotlib.figure as mpl_figure mpl_figure.Figure.show = lambda self, *a, **k: None diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py index a548d596f59..0e5a8ad3097 100644 --- a/doc/sphinxext/_lite_setup_cell_3d.py +++ b/doc/sphinxext/_lite_setup_cell_3d.py @@ -20,15 +20,51 @@ # approximate MNE's Brain look with solid-colored meshes: a two-tone # curvature base (light gyri + dark sulci) plus many thin 'hot' bands # for the activation, on a black background with even scene lighting. -# Static, one time point, no time slider yet. Fully guarded โ€” any -# failure prints a message so the notebook completes. Returns a stub -# 'brain' whose methods (add_foci/add_text/show_view/...) are safe -# no-ops, so tutorials that call brain.add_foci(...) after plot() work. +# Static, one time point, no time slider yet. +# +# A failed render prints and lets the notebook carry on, which is the opposite +# of how the data fetch in the base cell behaves. That is deliberate: a file +# missing there means the docs build is broken and should say so, while this +# whole shim stands in for a stack with no WebAssembly build at all, so failing +# hard would take out every 3D notebook rather than report one bug. +# +# The stub 'brain' it returns makes the decorating calls (add_foci/add_text/ +# show_view/...) no-ops so the rest of the notebook still runs. screenshot() is +# the one exception, and it raises: see below. +# Say once per session that what the browser draws is not what the rendered +# docs show, so a reader comparing the two is not left guessing. pyvista-js has +# no scalar colormap, so activation arrives as discrete solid bands rather than +# a continuous scale, there is no colorbar or time slider, and the hemispheres +# are drawn side by side rather than in anatomical position. +_lite_3d_noted = False + + +def _lite_note_3d_approximation(): + global _lite_3d_noted + if _lite_3d_noted: + return + _lite_3d_noted = True + print( + "[JupyterLite] 3D drawn with pyvista-js: activation is shown as solid " + "colour bands at a single time point, with the hemispheres side by " + "side and no colorbar. The figure in the rendered docs is MNE's full " + "Brain view and will not look the same." + ) + + class _LiteBrain: def screenshot(self, *args, **kwargs): - import numpy as _np - - return _np.zeros((2, 2, 3), dtype="uint8") + # No blank array here. vtk.js draws into a browser canvas that Python + # cannot read back, so there is no image to return, and handing back a + # blank one is worse than failing: 10_publication_figure crops its + # screenshot and shows before/after, so it would publish two black + # squares as though they were the real thing. A notebook that needs a + # screenshot belongs in JUPYTERLITE_EXCLUDE instead. + raise NotImplementedError( + "brain.screenshot() is not available in JupyterLite: the vtk.js " + "renderer draws to a browser canvas that Python cannot read back. " + "Run this notebook locally to capture the scene." + ) def __getattr__(self, _name): return lambda *args, **kwargs: None @@ -68,7 +104,17 @@ def _lite_stc_plot(self, *args, **kwargs): else: _ti = int(_np.argmin(_np.abs(self.times - _init))) _hot = _cmaps["hot"] - _N = 10 + # Tuned against the inflated FreeSurfer surfaces MNE ships, whose + # coordinates are in mm. + _N = 10 # activation value bands + _BLOB_MM = 12.0 # colour a surface vertex from an active source within + # this radius, so a single-vertex source reads as a blob and not a dot + _HEMI_MM = 60.0 # push the hemispheres apart so they do not overlap + _LIFT = 0.02 # raise each band off the surface to avoid z-fighting + _HOT_LO, _HOT_HI = 0.25, 0.66 # slice of 'hot' to use; its ends are + # near-black and near-white, which read as background here + _SPARSE_P90 = 0.05 # below this fraction of the max the 90th pct means + _SPARSE_FLOOR = 0.4 # the data is sparse, so threshold on the max def _flat(_t): return _np.hstack( @@ -124,9 +170,9 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): if _act.any(): _atree = _KDTree(_rr[_vno][_act]) _ad, _ai = _atree.query(_rr) - _scal = _np.where(_ad <= 12.0, _sv[_act][_ai], 0.0) + _scal = _np.where(_ad <= _BLOB_MM, _sv[_act][_ai], 0.0) # offset hemispheres along x so they do not overlap - _off = -60.0 if _h == "lh" else 60.0 + _off = -_HEMI_MM if _h == "lh" else _HEMI_MM _pts = _np.round(_rr, 2) _pts[:, 0] = _pts[:, 0] + _off _cen = _pts.mean(0) @@ -151,7 +197,7 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): # keep the background gray: for sparse point sources the # 90th pct is ~0 (most of the brain is zero), which would # paint everything, so fall back to a fraction of the max. - _fmin = _p90 if _p90 > _fmax * 0.05 else _fmax * 0.4 + _fmin = _p90 if _p90 > _fmax * _SPARSE_P90 else _fmax * _SPARSE_FLOOR if _fmax > _fmin: _edges = _np.linspace(_fmin, _fmax, _N + 1) for _i in range(_N): @@ -161,9 +207,9 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): _m = _fv >= _edges[_i] if int(_m.sum()) == 0: continue - _rgb = _hot(0.25 + 0.41 * (_i / (_N - 1))) + _rgb = _hot(_HOT_LO + (_HOT_HI - _HOT_LO) * (_i / (_N - 1))) _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) - _s = _sub(_pts, _tris, _m, 0.02, _cen) + _s = _sub(_pts, _tris, _m, _LIFT, _cen) if _s is not None: _plotter.add_mesh( _pv.PolyData(points=_s[0], faces=_flat(_s[1])), @@ -179,6 +225,7 @@ def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): except Exception: pass _plotter.show() + _lite_note_3d_approximation() except Exception as _e: print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) return _LiteBrain() @@ -307,6 +354,7 @@ def _lite_plot_sparse_source_estimates( except Exception: pass _plotter.show() + _lite_note_3d_approximation() except Exception as _e: print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) @@ -355,16 +403,15 @@ def _lite_displayhook(self, result=None): # unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH # once threadpoolctl 3.7.0 is released and Pyodide bundles it. try: - import os as _os - import threadpoolctl as _tpc + import threadpoolctl def _find_libraries_pyodide(self): from pyodide_js._module import LDSO for _fp in LDSO.loadedLibsByName.as_py_json(): - if _os.path.exists(_fp): + if Path(_fp).exists(): self._make_controller_from_path(_fp) - _tpc.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide + threadpoolctl.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide except Exception: pass From 48186a6056c5030b319e28fa4eafbede5064c7b0 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 13:44:59 -0400 Subject: [PATCH 10/14] DOC: name the right caller of the requests shim It is fetch_fsaverage reached from montage.py, not fetch_infant_template: that one is only mentioned in prose by 25_automated_coreg, and the tutorial that really calls it is already excluded from the build. --- doc/sphinxext/_lite_setup_cell.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 60af172d23a..d065665d4b6 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -76,9 +76,12 @@ sys.modules["multiprocessing.pool"] = _mp.pool # Route requests over the browser's own transport so the downloads that still -# go through pooch work here. The one that matters is fetch_infant_template -# (25_automated_coreg), which reaches pooch.retrieve -> pooch.HTTPDownloader -# -> requests, and whose files live on github.com rather than OSF. +# go through pooch work here. That path is pooch.retrieve -> +# pooch.HTTPDownloader -> requests, used by the fetchers whose files live off +# the docs site (fetch_fsaverage and friends) and so are not in the copy +# html_extra_path serves. Most of their callers are on JUPYTERLITE_EXCLUDE +# already; examples/visualization/montage.py is the one that still reaches +# this, via fetch_fsaverage. # # XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call # _lite_fetch_rel uses below: open_url reports no status, so a 404 page came From b868a145025058c2210213720b7cc5cbe27fd13d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Wed, 26 Aug 2026 15:00:52 -0400 Subject: [PATCH 11/14] DOC: nothing badged reaches the requests shim any more montage.py was the last one, and it is back on the exclude list now that its rename is fixed. The shim stays: the list is the only thing keeping it unused, and a notebook added tomorrow would otherwise hit a Pyodide socket error instead of a real HTTP one. --- doc/sphinxext/_lite_setup_cell.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index d065665d4b6..672eeca69a0 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -77,11 +77,13 @@ # Route requests over the browser's own transport so the downloads that still # go through pooch work here. That path is pooch.retrieve -> -# pooch.HTTPDownloader -> requests, used by the fetchers whose files live off -# the docs site (fetch_fsaverage and friends) and so are not in the copy -# html_extra_path serves. Most of their callers are on JUPYTERLITE_EXCLUDE -# already; examples/visualization/montage.py is the one that still reaches -# this, via fetch_fsaverage. +# pooch.HTTPDownloader -> requests, taken by the fetchers whose files live off +# the docs site (fetch_fsaverage, fetch_infant_template and the parcellation +# ones) and so are not in the copy html_extra_path serves. Every notebook that +# calls one is on JUPYTERLITE_EXCLUDE today, so nothing reaches this on the +# badged pages; it stays because that list is the only thing keeping it that +# way, and a notebook added to the gallery tomorrow would otherwise fail here +# with a Pyodide socket error rather than a real HTTP one. # # XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call # _lite_fetch_rel uses below: open_url reports no status, so a 404 page came From fa816e2d4624af863c25a5a979cbafee40b512a6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 11 Sep 2026 12:04:18 -0400 Subject: [PATCH 12/14] Simplify --- doc/sphinxext/_lite_setup_cell.py | 44 ++- doc/sphinxext/_lite_setup_cell_3d.py | 417 ------------------------ doc/sphinxext/jupyterlite_setup_cell.py | 19 +- mne/viz/_3d.py | 5 +- mne/viz/backends/_lite.py | 107 ++++-- mne/viz/backends/renderer.py | 9 +- mne/viz/backends/tests/test_renderer.py | 62 +++- mne/viz/ui_events.py | 2 +- pyproject.toml | 2 +- 9 files changed, 173 insertions(+), 494 deletions(-) delete mode 100644 doc/sphinxext/_lite_setup_cell_3d.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 672eeca69a0..101b97b7dcc 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -106,6 +106,7 @@ def _pyodide_send(self, request, **kwargs): _xhr.send() response = requests.Response() response.status_code = _xhr.status + response.reason = _xhr.statusText # what raise_for_status() reports response.url = request.url response.raw = io.BytesIO(bytes(_xhr.response.to_py())) return response @@ -746,21 +747,40 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): import matplotlib.figure as mpl_figure mpl_figure.Figure.show = lambda self, *a, **k: None -import importlib -_viz_utils = importlib.import_module("mne.viz.utils") +# A plot call that is also a cell's last expression returns its Figure, which +# Jupyter would echo as Out[] a second time after plt_show already displayed +# it. Drop that echo for Figures and lists of them (ica.plot_properties); other +# results are untouched. Guarded: a surprise here should keep the harmless +# double render rather than break the setup 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) -# Also display+close via IPython for paths that call plt_show -# directly, so figures render exactly once. -def _pyodide_plt_show(show=True, fig=None, **kwargs): - if not show: - return - import IPython.display + _lite_dh.__call__ = _lite_displayhook +except Exception: + pass - _f = fig if fig is not None else plt.gcf() - IPython.display.display(_f) - plt.close(_f) +# threadpoolctl 3.6.0 still calls Pyodide's deprecated JsProxy.as_object_map(), +# which warns from mne.sys_info(); as_py_json() gives the same paths. +# TODO VERSION: fixed upstream 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) -_viz_utils.plt_show = _pyodide_plt_show + threadpoolctl.ThreadpoolController._find_libraries_pyodide = _find_libraries_pyodide +except Exception: + pass diff --git a/doc/sphinxext/_lite_setup_cell_3d.py b/doc/sphinxext/_lite_setup_cell_3d.py deleted file mode 100644 index 0e5a8ad3097..00000000000 --- a/doc/sphinxext/_lite_setup_cell_3d.py +++ /dev/null @@ -1,417 +0,0 @@ -# Authors: The MNE-Python contributors. -# License: BSD-3-Clause -# Copyright the MNE-Python contributors. - -# The experimental part of the browser setup, kept apart from the rest so the -# solid ground and the shifting ground are easy to tell apart. Everything here -# stands in for MNE's Brain/VTK stack, which has no WebAssembly build, and is -# the part most likely to be dropped as pyvista-js gains features upstream. -# Appended after the base cell, which it depends on: the second block below -# uses the matplotlib-inline shim that cell installs. -# This runs as a continuation of the base cell, in the same namespace, so it -# reads names that cell defined (mne, plt, the fetch helpers) rather than -# importing them again; F821 is off for that reason, not to hide typos. -# ruff: noqa: E402, F704, F821, I001 - -# --- JupyterLite setup cell, 3D ----------------------------------------------- -# EXPERIMENTAL 3D: MNE's normal Brain/VTK stack can't load in WASM, so -# route SourceEstimate.plot() through pyvista-js (vtk.js) instead. -# pyvista-js (0.15) has no scalar colormap in its renderer, so we -# approximate MNE's Brain look with solid-colored meshes: a two-tone -# curvature base (light gyri + dark sulci) plus many thin 'hot' bands -# for the activation, on a black background with even scene lighting. -# Static, one time point, no time slider yet. -# -# A failed render prints and lets the notebook carry on, which is the opposite -# of how the data fetch in the base cell behaves. That is deliberate: a file -# missing there means the docs build is broken and should say so, while this -# whole shim stands in for a stack with no WebAssembly build at all, so failing -# hard would take out every 3D notebook rather than report one bug. -# -# The stub 'brain' it returns makes the decorating calls (add_foci/add_text/ -# show_view/...) no-ops so the rest of the notebook still runs. screenshot() is -# the one exception, and it raises: see below. -# Say once per session that what the browser draws is not what the rendered -# docs show, so a reader comparing the two is not left guessing. pyvista-js has -# no scalar colormap, so activation arrives as discrete solid bands rather than -# a continuous scale, there is no colorbar or time slider, and the hemispheres -# are drawn side by side rather than in anatomical position. -_lite_3d_noted = False - - -def _lite_note_3d_approximation(): - global _lite_3d_noted - if _lite_3d_noted: - return - _lite_3d_noted = True - print( - "[JupyterLite] 3D drawn with pyvista-js: activation is shown as solid " - "colour bands at a single time point, with the hemispheres side by " - "side and no colorbar. The figure in the rendered docs is MNE's full " - "Brain view and will not look the same." - ) - - -class _LiteBrain: - def screenshot(self, *args, **kwargs): - # No blank array here. vtk.js draws into a browser canvas that Python - # cannot read back, so there is no image to return, and handing back a - # blank one is worse than failing: 10_publication_figure crops its - # screenshot and shows before/after, so it would publish two black - # squares as though they were the real thing. A notebook that needs a - # screenshot belongs in JUPYTERLITE_EXCLUDE instead. - raise NotImplementedError( - "brain.screenshot() is not available in JupyterLite: the vtk.js " - "renderer draws to a browser canvas that Python cannot read back. " - "Run this notebook locally to capture the scene." - ) - - def __getattr__(self, _name): - return lambda *args, **kwargs: None - - -def _lite_stc_plot(self, *args, **kwargs): - try: - import numpy as _np - import nibabel as _nib - from scipy.spatial import cKDTree as _KDTree - from matplotlib import colormaps as _cmaps - import pyvista_js as _pv - - _subj = ( - kwargs.get("subject") - or (args[0] if args and isinstance(args[0], str) else None) - or "sample" - ) - _sdir = kwargs.get("subjects_dir") - # kept as a str on both branches: it is concatenated below - _sdir = ( - str(_sdir) - if _sdir is not None - else str(_lite_data_path("MNE-sample-data/subjects")) - ) - # surfaces are fetched relative to the served mne_data root, so - # derive that from subjects_dir rather than assuming sample -- - # a dataset may keep its FreeSurfer subjects under its own folder. - _rel_sdir = ( - _lite_rel_to_data(_sdir) - if _lite_rel_to_data(_sdir) is not None - else "MNE-sample-data/subjects" - ) - _init = kwargs.get("initial_time", None) - if _init is None: - _ti = int(_np.argmax(_np.abs(self.data).mean(0))) - else: - _ti = int(_np.argmin(_np.abs(self.times - _init))) - _hot = _cmaps["hot"] - # Tuned against the inflated FreeSurfer surfaces MNE ships, whose - # coordinates are in mm. - _N = 10 # activation value bands - _BLOB_MM = 12.0 # colour a surface vertex from an active source within - # this radius, so a single-vertex source reads as a blob and not a dot - _HEMI_MM = 60.0 # push the hemispheres apart so they do not overlap - _LIFT = 0.02 # raise each band off the surface to avoid z-fighting - _HOT_LO, _HOT_HI = 0.25, 0.66 # slice of 'hot' to use; its ends are - # near-black and near-white, which read as background here - _SPARSE_P90 = 0.05 # below this fraction of the max the 90th pct means - _SPARSE_FLOOR = 0.4 # the data is sparse, so threshold on the max - - def _flat(_t): - return _np.hstack( - [_np.full((len(_t), 1), 3, dtype=_np.int64), _t.astype(_np.int64)] - ).ravel() - - def _sub(_pts, _tris, _mask, _lift=0.0, _cen=None): - _sel = _tris[_mask] - if len(_sel) == 0: - return None - _u, _iv = _np.unique(_sel, return_inverse=True) - _p = _pts[_u] - if _lift and _cen is not None: - _p = _cen + (_p - _cen) * (1.0 + _lift) - return _p, _iv.reshape(-1, 3) - - _plotter = _pv.Plotter() - _plotter.background_color = "black" - # even lighting so the surface isn't black when rotated - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _nlh = len(self.vertices[0]) - _hemis = (("lh", 0, self.vertices[0]), ("rh", 1, self.vertices[1])) - for _h, _hi, _vno in _hemis: - if len(_vno) == 0: - continue - _pre = _rel_sdir + "/" + _subj + "/surf/" + _h - _lite_fetch_rel(_pre + ".inflated") - _lite_fetch_rel(_pre + ".curv") - _bpath = _sdir + "/" + _subj + "/surf/" + _h - _rr, _tris = mne.read_surface(_bpath + ".inflated") - _cv = _nib.freesurfer.read_morph_data(_bpath + ".curv") - _hdata = self.data[:_nlh] if _hi == 0 else self.data[_nlh:] - # color each surface vertex from the nearest ACTIVE source - # within a small radius, so single-vertex (point) sources - # show as visible blobs and dense sources fill in as usual - _sv = _hdata[:, _ti].astype(float) - _act = _sv != 0 - _scal = _np.zeros(len(_rr)) - if _act.any(): - _atree = _KDTree(_rr[_vno][_act]) - _ad, _ai = _atree.query(_rr) - _scal = _np.where(_ad <= _BLOB_MM, _sv[_act][_ai], 0.0) - # offset hemispheres along x so they do not overlap - _off = -_HEMI_MM if _h == "lh" else _HEMI_MM - _pts = _np.round(_rr, 2) - _pts[:, 0] = _pts[:, 0] + _off - _cen = _pts.mean(0) - # curvature base: light gyri (curv<0) + dark sulci (curv>=0) - _fc = _cv[_tris].mean(1) - for _cm, _col in ( - (_fc < 0, (0.68, 0.68, 0.68)), - (_fc >= 0, (0.38, 0.38, 0.38)), - ): - _s = _sub(_pts, _tris, _cm) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # activation as a smooth hot gradient in N value bands, - # each lifted 2% off the surface to avoid z-fighting - _fv = _scal[_tris].mean(1) - _p90 = _np.percentile(_scal, 90.0) - _fmax = float(_scal.max()) - # keep the background gray: for sparse point sources the - # 90th pct is ~0 (most of the brain is zero), which would - # paint everything, so fall back to a fraction of the max. - _fmin = _p90 if _p90 > _fmax * _SPARSE_P90 else _fmax * _SPARSE_FLOOR - if _fmax > _fmin: - _edges = _np.linspace(_fmin, _fmax, _N + 1) - for _i in range(_N): - if _i < _N - 1: - _m = (_fv >= _edges[_i]) & (_fv < _edges[_i + 1]) - else: - _m = _fv >= _edges[_i] - if int(_m.sum()) == 0: - continue - _rgb = _hot(_HOT_LO + (_HOT_HI - _HOT_LO) * (_i / (_N - 1))) - _col = (float(_rgb[0]), float(_rgb[1]), float(_rgb[2])) - _s = _sub(_pts, _tris, _m, _LIFT, _cen) - if _s is not None: - _plotter.add_mesh( - _pv.PolyData(points=_s[0], faces=_flat(_s[1])), - color=_col, - smooth_shading=True, - ) - # Open on the lateral profile (camera along the medial-lateral - # X axis, superior up), like native MNE, instead of vtk.js's - # default anterior/face-on view. Guarded so a missing - # view_vector never costs us the render. - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - _lite_note_3d_approximation() - except Exception as _e: - print("[JupyterLite] pyvista-js 3D render unavailable: " + repr(_e)) - return _LiteBrain() - - -mne.SourceEstimate.plot = _lite_stc_plot - - -# EXPERIMENTAL 3D: plot_sparse_source_estimates builds its 3D renderer -# BEFORE the time-course figure, so in WASM the whole call dies and the -# notebook loses both halves. Rebuild it here: the same glass brain from -# the source space and a marker per active dipole via pyvista-js, plus -# the matplotlib time courses (which are the quantitative half). Same -# approach as the SourceEstimate.plot shim above. -def _lite_plot_sparse_source_estimates( - src, - stcs, - colors=None, - linewidth=2, - fontsize=18, - bgcolor=(0.05, 0, 0.1), - opacity=0.2, - brain_color=(0.7,) * 3, - show=True, - high_resolution=False, - fig_name=None, - fig_number=None, - labels=None, - modes=("cone", "sphere"), - scale_factors=(1, 0.6), - **kwargs, -): - import numpy as _np - from itertools import cycle as _cycle - from matplotlib.colors import to_rgb as _to_rgb - - if not isinstance(stcs, list): - stcs = [stcs] - _lhp = src[0]["rr"] - _pts = _np.r_[_lhp, src[1]["rr"]] * 170 - _nrm = _np.r_[src[0]["nn"], src[1]["nn"]] - # use_tris is the decimated mesh and can be None on some source - # spaces; fall back to the full tris in that case. - _lt = src[0]["tris"] if high_resolution else src[0]["use_tris"] - _rt = src[1]["tris"] if high_resolution else src[1]["use_tris"] - if _lt is None or _rt is None: - _lt, _rt = src[0]["tris"], src[1]["tris"] - _faces = _np.r_[_lt, len(_lhp) + _rt] - _vertnos = [_np.r_[_s.lh_vertno, len(_lhp) + _s.rh_vertno] for _s in stcs] - _uniq = _np.unique(_np.concatenate(_vertnos).ravel()) - # --- time courses ------------------------------------------------- - _fig = plt.figure(fig_number, layout="constrained") - _fig.clf() - _ax = _fig.add_subplot(111) - _cyc = _cycle( - colors - if colors is not None - else plt.rcParams["axes.prop_cycle"].by_key()["color"] - ) - _marks = [] - for _v in _uniq: - _ind = [_k for _k, _vn in enumerate(_vertnos) if _v in _vn] - _c = next(_cyc) - _marks.append((int(_v), _to_rgb(_c), len(_ind) > 1)) - for _k in _ind: - _m = _vertnos[_k] == _v - _ax.plot( - 1e3 * stcs[_k].times, - 1e9 * stcs[_k].data[_m].ravel(), - c=_c, - linewidth=linewidth, - ) - _ax.set_xlabel("Time (ms)", fontsize=fontsize) - _ax.set_ylabel("Source amplitude (nAm)", fontsize=fontsize) - if fig_name is not None: - _ax.set_title(fig_name) - _pyodide_plt_show(show) - # --- glass brain + dipole markers --------------------------------- - try: - import pyvista_js as _pv - - _plotter = _pv.Plotter() - _plotter.background_color = tuple( - float(min(max(_x, 0.0), 1.0)) for _x in bgcolor - ) - for _lp in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): - _plotter.add_light( - _pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) - ) - _flat_faces = _np.hstack( - [_np.full((len(_faces), 1), 3, dtype=_np.int32), _faces.astype(_np.int32)] - ).ravel() - _plotter.add_mesh( - _pv.PolyData(points=_pts.astype(_np.float32), faces=_flat_faces), - color=tuple(float(_x) for _x in brain_color), - opacity=float(opacity), - smooth_shading=True, - ) - for _v, _col, _common in _marks: - _sf = float(scale_factors[1] if _common else scale_factors[0]) - _mode = modes[1] if _common else modes[0] - _xyz = tuple(float(_q) for _q in _pts[_v]) - if _mode == "sphere": - _glyph = _pv.Sphere(radius=_sf, center=_xyz) - else: - _glyph = _pv.Cone( - center=_xyz, - direction=tuple(float(_q) for _q in _nrm[_v]), - height=2.0 * _sf, - radius=_sf, - ) - _plotter.add_mesh(_glyph, color=_col, smooth_shading=True) - try: - _plotter.view_vector((-1.0, 0.0, 0.0), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - _plotter.show() - _lite_note_3d_approximation() - except Exception as _e: - print("[JupyterLite] pyvista-js glass brain unavailable: " + repr(_e)) - - -mne.viz.plot_sparse_source_estimates = _lite_plot_sparse_source_estimates - -# Each MNE plot is rendered once by _pyodide_plt_show above (display()). -# When a plot call is also a cell's last expression, the method returns -# the Figure, which Jupyter echoes a SECOND time as the Out[] result -# (the duplicate seen below inline plots). Drop that redundant echo for -# Figures (and pure lists of Figures, e.g. ica.plot_properties) so each -# plot appears exactly once. Non-figure results (numbers, DataFrames, -# reprs) are untouched, and raw matplotlib figures never shown still -# render via the inline backend's end-of-cell flush, so nothing hides. -# Wrapped in try/except (like the patches below): if anything about -# the displayhook is unexpected, silently keep the current behavior -# (harmless double render) rather than breaking the setup cell. -try: - _lite_dh = type(IPython.get_ipython().displayhook) - if not getattr(_lite_dh, "_lite_no_fig_echo", False): - _lite_dh_call = _lite_dh.__call__ - - def _lite_displayhook(self, result=None): - if isinstance(result, mpl_figure.Figure): - result = None - elif ( - isinstance(result, (list, tuple)) - and result - and all(isinstance(_x, mpl_figure.Figure) for _x in result) - ): - result = None - return _lite_dh_call(self, result) - - _lite_dh.__call__ = _lite_displayhook - _lite_dh._lite_no_fig_echo = True -except Exception: - pass - -# Real fix (not a warnings filter) for the threadpoolctl Pyodide -# RuntimeWarning seen via mne.sys_info(): threadpoolctl 3.6.0 (latest -# release) still calls the deprecated Pyodide JsProxy.as_object_map(). -# Pyodide's own message says to use as_py_json() instead; both yield the -# same library filepaths, so we swap the call at its source. This removes -# the deprecated API usage entirely, so the warning is never emitted. -# The upstream fix is already merged (joblib/threadpoolctl#201) but -# unreleased; Pyodide bundles the released 3.6.0 wheel. DROP THIS PATCH -# once threadpoolctl 3.7.0 is released and Pyodide bundles it. -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 diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py index 298347ce5aa..957acbf10a0 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -2,12 +2,9 @@ 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`` and -``_lite_setup_cell_3d.py`` as ordinary Python, so ruff lints and formats it; -this module only reads those files and joins them into the string the browser -kernel needs. The 3D half is kept separate because it stands in for MNE's -Brain/VTK stack and is the part most likely to change as pyvista-js gains -features upstream. +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, appends the renderer switch, and checks the result 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 @@ -24,6 +21,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import ast from pathlib import Path from jupyterlite_lite_renderer import LITE_RENDERER_CELL @@ -43,8 +41,9 @@ def _read(name): return _body[_body.index("\n") + 1 :] -# Order matters: the 3D half reads the matplotlib shim the base half installs, -# and the renderer goes last so MNE is already imported by the time it runs. -LITE_SETUP_CELL = ( - _read("_lite_setup_cell.py") + _read("_lite_setup_cell_3d.py") + LITE_RENDERER_CELL +# the renderer goes last so MNE is already imported by the time it runs +LITE_SETUP_CELL = _read("_lite_setup_cell.py") + LITE_RENDERER_CELL +# 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/viz/_3d.py b/mne/viz/_3d.py index 8a3856dc665..9a97dc350c4 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2802,7 +2802,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/backends/_lite.py b/mne/viz/backends/_lite.py index 6d5309834b2..fb0a3b5ccb5 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 802db2ab79a..43a19b51434 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, scale=1.0) 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 86b196c7e72..d45b26fcba7 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"}, ] From 4e83ff2ac83318d7635a23309941ff16d4cb9bde Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 11 Sep 2026 12:06:26 -0400 Subject: [PATCH 13/14] FIX: Restore comment marker dropped in #14296 --- mne/_fiff/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/_fiff/utils.py b/mne/_fiff/utils.py index 57e1171981b..24550f71ae3 100644 --- a/mne/_fiff/utils.py +++ b/mne/_fiff/utils.py @@ -50,7 +50,7 @@ def _check_orig_units(orig_units): # character in shift-jis encoding, it gets read in as utf-8, where `\xca` is an # (uppercase) รŠ. Elsewhere in the codebase we call a `.lower()` on these strings # so if the keys here aren't also `.lower()` then the key will fail to be - matched. + # matched. remap_dict["\x83\xcaV".lower()] = "ยตV" # for shift-jis mu, use micro if unit.lower() in remap_dict: orig_units_remapped[ch_name] = remap_dict[unit.lower()] From 9ea410d22026bff78786180eea811f0e91eaf2d6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 11 Sep 2026 12:28:19 -0400 Subject: [PATCH 14/14] Route through _check_fname --- doc/sphinxext/_lite_setup_cell.py | 636 +++++---------------- doc/sphinxext/jupyterlite_lite_renderer.py | 23 - doc/sphinxext/jupyterlite_setup_cell.py | 13 +- mne/event.py | 3 +- mne/label.py | 1 + mne/surface.py | 1 + mne/transforms.py | 4 +- mne/viz/_brain/surface.py | 8 +- 8 files changed, 168 insertions(+), 521 deletions(-) delete mode 100644 doc/sphinxext/jupyterlite_lite_renderer.py diff --git a/doc/sphinxext/_lite_setup_cell.py b/doc/sphinxext/_lite_setup_cell.py index 101b97b7dcc..32d5daa2219 100644 --- a/doc/sphinxext/_lite_setup_cell.py +++ b/doc/sphinxext/_lite_setup_cell.py @@ -2,45 +2,25 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -# This file is notebook source rather than a module: it installs packages with -# a top-level ``await`` and imports them only afterwards, so the rules about -# import position, await position and import order do not apply to it. Ruff -# still lints and formats everything else here, which is the point of keeping -# it as a real file instead of a string. +# 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 - -# Naming: everything this cell defines lands in the notebook's own namespace, -# so anything it invents is _-prefixed and cannot shadow a variable the -# tutorial goes on to use. Module imports are left plain: a tutorial importing -# the same module binds the same object, so there is nothing to protect. -# `mne_data_path` is the deliberate exception, since a reader may want it. - -# Layout, in the order the sections appear below: -# 1. install MNE into the browser kernel -# 2. patch what Pyodide lacks, before MNE is imported -# 3. work out where the data is served and copy the core of it in -# 4. import MNE and point its datasets at that copy -# 5. define the fetch helpers everything after this point uses -# 6. tell MNE where each dataset lives -# 7. wrap the readers, in three groups by how much work each needs -# 8. stub out what WebAssembly cannot do +# +# 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 automatically added to the start of each notebook. -# It installs MNE and patches the browser environment for Pyodide. -# Downloading this notebook to run it locally? Delete this cell first: -# piplite exists only inside JupyterLite, and a local MNE needs none of -# the patches below. +# ๐Ÿ’ก 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 -# Use piplite (not micropip) so the locally-built development MNE wheel -# bundled into the JupyterLite build is preferred over the older PyPI -# release: piplite checks the local index first and falls back to PyPI -# for dependencies. -# keep_going=True so a dependency with no pure-Python wheel is reported -# at the end rather than aborting the whole install on the first one. +# 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", @@ -61,41 +41,15 @@ # === 2. Pyodide compatibility, before MNE is imported ======================== import sys import os -import io import inspect +import io from pathlib import Path -# Mock multiprocessing โ€” missing in Pyodide but imported by joblib -from unittest.mock import MagicMock - -if "multiprocessing" not in sys.modules: - _mp = MagicMock() - _mp.cpu_count.return_value = 1 - sys.modules["multiprocessing"] = _mp - sys.modules["multiprocessing.util"] = _mp.util - sys.modules["multiprocessing.pool"] = _mp.pool - -# Route requests over the browser's own transport so the downloads that still -# go through pooch work here. That path is pooch.retrieve -> -# pooch.HTTPDownloader -> requests, taken by the fetchers whose files live off -# the docs site (fetch_fsaverage, fetch_infant_template and the parcellation -# ones) and so are not in the copy html_extra_path serves. Every notebook that -# calls one is on JUPYTERLITE_EXCLUDE today, so nothing reaches this on the -# badged pages; it stays because that list is the only thing keeping it that -# way, and a notebook added to the gallery tomorrow would otherwise fail here -# with a Pyodide socket error rather than a real HTTP one. -# -# XMLHttpRequest rather than pyodide.http.open_url, and the same blocking call -# _lite_fetch_rel uses below: open_url reports no status, so a 404 page came -# back looking like a successful 200 and pooch wrote the error page to disk, -# only failing later on a confusing hash mismatch. XHR gives the real status, -# which is what pooch's raise_for_status() needs. Nothing is caught here: the -# browser is the only transport available, so a failure has no fallback worth -# taking and the real error is more useful than a substituted one. +# 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 -_orig_send = requests.Session.send - def _pyodide_send(self, request, **kwargs): from js import XMLHttpRequest @@ -106,7 +60,7 @@ def _pyodide_send(self, request, **kwargs): _xhr.send() response = requests.Response() response.status_code = _xhr.status - response.reason = _xhr.statusText # what raise_for_status() reports + response.reason = _xhr.statusText response.url = request.url response.raw = io.BytesIO(bytes(_xhr.response.to_py())) return response @@ -115,98 +69,19 @@ def _pyodide_send(self, request, **kwargs): requests.Session.send = _pyodide_send # === 3. Where the data comes from =========================================== -# /drive/ in Pyodide requires Cross-Origin-Isolation headers -# (COOP/COEP) which many static servers (e.g. CircleCI artifacts) -# do not send. Fetch the data over HTTP into /tmp/mne_data instead: -# same-origin, no CORS. The data is served at the docs root -# (/mne_data/...) via Sphinx html_extra_path. -# Pyodide may run in a web worker (no `window`); `location` exists -# in both the main thread and workers, so use it to find the docs -# root by splitting on '/lite/'. -import pyodide.http +# 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 -try: - _page = str(js.location.href) -except Exception: - _page = str(js.window.location.href) -_base = _page.split("/lite/")[0] + "/mne_data/" +_base = str(js.location.href).split("/lite/")[0] + "/mne_data/" mne_data_path = "/tmp/mne_data" _mne_data_root = Path(mne_data_path) -_sample_dir = _mne_data_root / "MNE-sample-data" -# Eager 'core': small, commonly-used sample files fetched once at -# notebook start. The heavy files (raw / filt raw / ernoise / fwd / -# inv / src, ~360 MB total) are intentionally omitted here -- they are -# fetched lazily on first read via the reader shims below, so each -# notebook only downloads the sample files it actually uses. -_sample_files = [ - "version.txt", - "MEG/sample/sample_audvis_raw-eve.fif", - "MEG/sample/sample_audvis_filt-0-40_raw-eve.fif", - "MEG/sample/sample_audvis_ecg-proj.fif", - "MEG/sample/sample_audvis-cov.fif", - "MEG/sample/sample_audvis-ave.fif", - "MEG/sample/sample_audvis-no-filter-ave.fif", - "MEG/sample/sample_audvis_raw-trans.fif", - "MEG/sample/sample_audvis-shrunk-cov.fif", - "MEG/sample/sample_audvis-meg-lh.stc", - "MEG/sample/sample_audvis-meg-rh.stc", - "subjects/sample/mri/T1.mgz", - "subjects/sample/surf/rh.pial", - "subjects/sample/surf/lh.pial", - "subjects/sample/surf/rh.white", - "subjects/sample/surf/lh.white", - "subjects/sample/label/lh.aparc.annot", - "subjects/sample/label/rh.aparc.annot", - "SSS/sss_cal_mgh.dat", - "SSS/ct_sparse_mgh.fif", -] -# These are served from the same origin as this page, so if the page loaded, -# the server is up: a miss here means the docs build did not stage the file, -# not that the network is flaky. Several of them (the SSS calibration pair, -# the surfaces read through nibabel) have no lazy path either, so a miss would -# otherwise surface as a confusing error many cells later. Collect every -# failure and raise once, naming them all, since one staging bug usually drops -# more than one file. -# print, not a logger: this cell runs in the browser kernel, not in the Sphinx -# process, so its output is simply what the notebook reader sees. -print("Fetching MNE sample data (once per session)...") -_missing = [] -for _f in _sample_files: - _dst = _sample_dir / _f - if _dst.exists(): - continue - _url = _base + "MNE-sample-data/" + _f - try: - _r = await pyodide.http.pyfetch(_url) - if _r.status != 200: - _missing.append(f"{_f} (HTTP {_r.status})") - continue - _d = await _r.bytes() - # a static server answers a missing path with its 404 page and a 200 - # status, so the body is the only way to tell the two apart - if _d[:4] == b"//`` (group B).""" + """Fetch ``rel_paths`` under ``//``.""" _rel = _lite_rel_to_data(subjects_dir if subjects_dir is not None else "") - if not subject or _rel is None: - return - _lite_fetch_optional(f"{_rel}/{subject}/{_p}" for _p in rel_paths) - - -def _lite_dataset_path(folder, probe=None): - """Build a ``data_path()`` that returns ``folder`` under the data root. + if subject and _rel is not None: + _lite_fetch_optional(f"{_rel}/{subject}/{_p}" for _p in rel_paths) - With ``probe``, the named file is fetched when data_path() is called. That - is what covers mtrf, whose .mat is read by scipy rather than by an MNE - reader, so nothing downstream would otherwise fetch it. - """ - - def _data_path(*args, **kwargs): - if probe is not None: - _lite_fetch_rel(folder + "/" + probe) - return _lite_data_path(folder) - - return _data_path +def _lite_wrap_reader(module, name, siblings=None): + """Wrap ``module.name`` to fetch its filename argument before it opens it. -def _lite_wrap_reader(module, name): - """Wrap ``module.name`` so its filename argument is fetched before it opens. - - The keyword to intercept is read off the wrapped function rather than - listed by hand: the readers below disagree about whether it is ``fname``, - ``filename`` or ``input_fname``, and a name written out here that drifted - from the real one would silently stop fetching for keyword callers. + ``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: - args = (_lite_fetch_if_under_mne_data(args[0]),) + args[1:] - elif arg in kwargs: - # move it to a positional argument, since it is no longer in kwargs - args = (_lite_fetch_if_under_mne_data(kwargs.pop(arg)),) + _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 that is handed a folder rather than a file.""" + """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: - _lite_fetch_dir(_rel) + _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) @@ -405,12 +202,7 @@ def _read(fname, *args, **kwargs): def _lite_rebind(name, old, new): - """Point every module that already imported ``old`` at ``new``. - - MNE lazy-loads most of itself, so a module that ran ``from x import f`` - before this cell holds its own reference and would not see the patch. - Modules imported afterwards pick it up on their own. - """ + """Point every MNE module that already imported ``old`` at ``new``.""" for _m in list(sys.modules.values()): if ( getattr(_m, "__name__", "").startswith("mne") @@ -419,20 +211,22 @@ def _lite_rebind(name, old, new): setattr(_m, name, new) -# === 6. Where MNE looks for each dataset ==================================== -# data_path() normally checks for the .tar.gz archive, not just the extracted -# folder, and would try to download from OSF when it does not find one. Point -# each dataset at its folder under the data root instead. The ones with a probe -# file are used by only a couple of notebooks each, so nothing is fetched until -# their data_path() is actually called. +# === 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 hands back the folder and lets the shimmed readers pull - # individual files, so a notebook that wants the EEGLAB recording does - # not also drag down the 39 MB movement raw. ("testing", "MNE-testing-data", None), - # datasets behind a single example each; only the files those examples - # read are served, and the readers below pull them individually ("ssvep", "ssvep-example-data", None), ("misc", "MNE-misc-data", None), ("eyelink", "MNE-eyelink-data", None), @@ -440,7 +234,6 @@ def _lite_rebind(name, old, new): ("refmeg_noise", "MNE-refmeg-noise-data", None), ("phantom_kernel", "MNE-phantom-kernel-data", None), ("multimodal", "MNE-multimodal-data", None), - # kiloword/erp_core for Epochs 30 & 40, mtrf for the decoding examples ("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"), @@ -449,140 +242,98 @@ def _lite_rebind(name, old, new): del _ds, _folder, _probe -# eegbci is addressed by subject and run rather than by path, so it needs its -# own shim rather than a row in the table above. -def _lite_eegbci_load_data(subjects, runs, *args, **kwargs): - # the parameter is `subjects`, matching MNE: 35_eeg_no_mri calls it by - # keyword, so a shim spelled `subject` would raise TypeError there +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] - _out = [] - for _s in _subjects: - for _r in _runs: - _rel = ( - "MNE-eegbci-data/files/eegmmidb/1.0.0/" - f"S{int(_s):03d}/S{int(_s):03d}R{int(_r):02d}.edf" - ) - _out.append(_lite_fetch_rel(_rel)) - return _out + 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 -# === 7. Reader overrides ==================================================== -# MNE functions need one of three treatments here, depending on how much they -# do before the file is actually opened. -# -# A. reads one file, and validates the name first. Nearly every MNE reader -# calls _check_fname(must_exist=True) before opening anything, so patching -# that single function covers read_info, read_evokeds, read_cov, -# read_label and the rest at once. A handful skip the validation, and are -# listed in a table instead. Nothing else is needed for this group. -# B. probes the filesystem before any reader runs. _get_head_surface calls -# os.path.exists, plot_bem globs bem/*.surf, so a fetch-on-open hook never -# fires. The candidates have to be on disk before the probe. -# C. one filename that means several files. read_raw_brainvision is handed -# only the .vhdr, opens it, reads the names of its .eeg and .vmrk out of -# it, and opens those, by which point we are inside the reader and it is -# too late to fetch. Same shape for EEGLAB (.set + .fdt), a .stc stem -# (lh + rh), and the formats that are a directory rather than a file. - -# --- A. reads one file ------------------------------------------------------ -# The general hook. Failures stay silent here so MNE still raises its own, -# clearer error for a file that genuinely is missing. +# === 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, *args, **kwargs): - if must_exist: +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_if_under_mne_data(fname) + _lite_fetch_rel(_rel) except Exception: - pass - return _orig_check_fname(fname, overwrite, must_exist, *args, **kwargs) + 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) -# The readers that open their file without validating it first, so the hook -# above never sees them. Each needs nothing but its file fetched. -for _module, _name in ( - (mne, "read_forward_solution"), - (mne.minimum_norm, "read_inverse_operator"), - (mne.io, "read_raw_fif"), - (mne.io, "read_raw"), - (mne, "read_source_spaces"), - (mne, "read_label"), - (mne, "read_epochs"), - (mne.io, "read_raw_edf"), - (mne, "read_bem_solution"), - (mne, "read_events"), - (mne.io, "read_raw_eyelink"), - (mne.chpi, "read_head_pos"), -): - _lite_wrap_reader(_module, _name) -del _module, _name -# The eyetracking heatmap example draws its stimulus straight through pyplot, -# and read_xdf goes through pyxdf. Neither is an MNE reader, but both take a -# path we serve, so they get the same treatment. -import matplotlib.pyplot as plt - -_orig_imread = plt.imread - - -def _lite_imread(fname, *args, **kwargs): - return _orig_imread(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) - -plt.imread = _lite_imread -# guarded: pyxdf has no pure-Python wheel on every Pyodide build, and only the -# XDF example needs it -try: +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 - _orig_load_xdf = pyxdf.load_xdf - - def _lite_load_xdf(fname, *args, **kwargs): - return _orig_load_xdf(_lite_fetch_if_under_mne_data(fname), *args, **kwargs) - - pyxdf.load_xdf = _lite_load_xdf + _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) -# --- B. probes the filesystem first ----------------------------------------- -# plot_alignment locates its head surface with os.path.exists before any reader -# runs. Fetch the candidates first and let MNE choose as it normally would. -# Several viz modules bind the name at import time, so rebind it wherever the -# original landed rather than in one known place. +# 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: - # same order MNE tries, so the browser picks the same - # surface the rendered docs did _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) -mne_fs._get_head_surface = _lite_get_head_surface -# import the 3D module first so the rebind is guaranteed to see it; -# anything imported later picks the patched name up on its own. -import mne.viz._3d # noqa: F401 - -_lite_rebind("_get_head_surface", _orig_get_head_surface, _lite_get_head_surface) -# same story for the skull surfaces, which _check_fname insists -# already exist on disk -_orig_get_skull_surface = mne_fs._get_skull_surface - - 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( @@ -590,17 +341,6 @@ def _lite_get_skull_surface(surf, subject, subjects_dir, bem=None, verbose=None) ) -mne_fs._get_skull_surface = _lite_get_skull_surface -_lite_rebind("_get_skull_surface", _orig_get_skull_surface, _lite_get_skull_surface) -# dig_mri_distances reaches a second, unrelated _get_head_surface, the -# one in mne/surface.py: it takes a list of candidate sources and -# probes bem/ with os.path.exists and glob, raising if the directory -# is absent, so the candidates have to land before it runs. -import mne.surface as mne_surface - -_orig_surface_head = mne_surface._get_head_surface - - def _lite_surface_head_surface( subject, source, subjects_dir, on_defects, raise_error=True ): @@ -613,79 +353,25 @@ def _lite_surface_head_surface( ) -# no _lite_rebind for this one: unlike the _freesurfer function above, nothing -# outside mne/surface.py imports it by name, so patching the module is enough. -mne_surface._get_head_surface = _lite_surface_head_surface -# plot_bem globs bem/*.surf and requires the bem directory to exist, -# so pull its three contours (plus the MRI it draws them on) down -# first; fetching creates the directory as a side effect. -_orig_plot_bem = mne.viz.plot_bem - - def _lite_plot_bem(subject=None, subjects_dir=None, *args, **kwargs): - _want = [ - "bem/inner_skull.surf", - "bem/outer_skull.surf", - "bem/outer_skin.surf", - "mri/" + str(kwargs.get("mri", "T1.mgz")), - ] + _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") - if _bs is not None: - _bs = [_bs] if isinstance(_bs, str) else list(_bs) - for _b in _bs: - _want += [f"surf/lh.{_b}", f"surf/rh.{_b}"] + 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 -# --- C. one filename, several files ----------------------------------------- -# An EEGLAB .set keeps its samples in a sibling .fdt, so fetch both. -_orig_read_raw_eeglab = mne.io.read_raw_eeglab - - -def _lite_read_raw_eeglab(input_fname, *args, **kwargs): - _rel = _lite_rel_to_data(input_fname) - if _rel is not None: - _lite_fetch_optional((_rel, _rel[:-4] + ".fdt")) - return _orig_read_raw_eeglab(input_fname, *args, **kwargs) - - -mne.io.read_raw_eeglab = _lite_read_raw_eeglab -# a BrainVision .vhdr is a text header pointing at a .eeg and a .vmrk -_orig_read_raw_brainvision = mne.io.read_raw_brainvision - - -def _lite_read_raw_brainvision(vhdr_fname, *args, **kwargs): - _rel = _lite_rel_to_data(vhdr_fname) - if _rel is not None: - _stem = _rel[:-5] if _rel.endswith(".vhdr") else _rel - _lite_fetch_optional((_rel, _stem + ".eeg", _stem + ".vmrk")) - return _orig_read_raw_brainvision(vhdr_fname, *args, **kwargs) - - -mne.io.read_raw_brainvision = _lite_read_raw_brainvision -# read_source_estimate is handed the stem of a .stc pair, so fetch -# both hemispheres before letting MNE resolve the name itself. -_orig_read_source_estimate = mne.read_source_estimate - - -def _lite_read_source_estimate(fname, *args, **kwargs): - _rel = _lite_rel_to_data(fname) - if _rel is not None: - _lite_fetch_optional(_rel + _suf for _suf in ("", "-lh.stc", "-rh.stc")) - return _orig_read_source_estimate(fname, *args, **kwargs) - - -mne.read_source_estimate = _lite_read_source_estimate -# read_raw_nirx and read_raw_egi open a folder, listed by its manifest -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) -# the odd one out in this group: the name is enough, but it points inside the -# installed package rather than at the data root. The logging tutorial builds -# a path into mne/**/tests, which the wheel excludes, so copy the served file -# to where the tutorial expects it rather than editing the tutorial. +# 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 @@ -695,9 +381,8 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): _p = Path(str(input_fname)) if _p.name == "test.sqd" and not _p.exists(): try: - _staged = _lite_fetch_rel("MNE-kit-testdata/test.sqd") _p.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(_staged, _p) + 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) @@ -705,15 +390,9 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): mne.io.read_raw_kit = _lite_read_raw_kit -# === 8. What WebAssembly cannot do ========================================== -# Pyodide/WASM has no OS threads, so MNE's ProgressBar background -# updater thread (used by the ProgressBar context manager, e.g. in -# permutation cluster tests) crashes with 'can't start new thread'. -# That thread only animates a cosmetic bar: the computation runs on -# the main thread and __exit__ writes the final state, so no-op its -# start/join. Only affects notebooks that use it; results are unchanged. -# Guarded because this is a private MNE path: if it is ever renamed, losing a -# cosmetic patch is better than failing every notebook at the setup cell. +# === 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 @@ -721,10 +400,6 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): progressbar._UpdateThread.join = lambda self, *args, **kwargs: None except Exception: pass -# tqdm also spawns its own monitor thread, which likewise can't start in -# WASM and emits a TqdmMonitorWarning. Setting monitor_interval=0 before -# any bar is created skips that thread entirely (bars still display). -# Guarded because tqdm is a transitive dependency that may not be installed. try: import tqdm @@ -732,27 +407,18 @@ def _lite_read_raw_kit(input_fname, *args, **kwargs): except Exception: pass -# Switch matplotlib to inline so figures render in the notebook. import IPython IPython.get_ipython().run_line_magic("matplotlib", "inline") -# Silence the spurious 'FigureCanvasAgg is non-interactive' warning that the -# inline Agg canvas raises from fig.show(). MNE's own plt_show no longer -# triggers it (gh-14076 taught it to call plt.show() on inline backends), but -# tutorials still call fig.show() directly -- 50_ssvep does it four times, and -# 10_background_stats once -- and those warn. Every path resolves fig.show on -# the class at call time, so a no-op here covers them all. Figures still -# render via the inline backend. +# 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 call that is also a cell's last expression returns its Figure, which -# Jupyter would echo as Out[] a second time after plt_show already displayed -# it. Drop that echo for Figures and lists of them (ica.plot_properties); other -# results are untouched. Guarded: a surprise here should keep the harmless -# double render rather than break the setup cell. +# 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__ @@ -767,10 +433,10 @@ def _lite_displayhook(self, result=None): except Exception: pass -# threadpoolctl 3.6.0 still calls Pyodide's deprecated JsProxy.as_object_map(), -# which warns from mne.sys_info(); as_py_json() gives the same paths. -# TODO VERSION: fixed upstream in joblib/threadpoolctl#201, drop once Pyodide -# bundles threadpoolctl >= 3.7.0 +# 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 @@ -784,3 +450,11 @@ def _find_libraries_pyodide(self): 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 index 957acbf10a0..8e2a4076fd7 100644 --- a/doc/sphinxext/jupyterlite_setup_cell.py +++ b/doc/sphinxext/jupyterlite_setup_cell.py @@ -4,7 +4,7 @@ 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, appends the renderer switch, and checks the result compiles. +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 @@ -24,11 +24,9 @@ import ast from pathlib import Path -from jupyterlite_lite_renderer import LITE_RENDERER_CELL - -# Each source file read below is split at this banner: everything after it is -# what the notebook runs, and what sits above it in that file (license header, -# ruff directives, notes for whoever edits it) stays behind. +# 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" @@ -41,8 +39,7 @@ def _read(name): return _body[_body.index("\n") + 1 :] -# the renderer goes last so MNE is already imported by the time it runs -LITE_SETUP_CELL = _read("_lite_setup_cell.py") + LITE_RENDERER_CELL +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/_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)