From 9ce4d0260ad643572e460aa24151ce89daf6c00d Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:37:05 -0400 Subject: [PATCH 1/2] ENH: add the JupyterLite browser runtime The setup cell that installs MNE into the browser kernel and patches what Pyodide does not provide, plus the pyvista-js backend for MNE's 3D renderer that it appends. Wired up by the next PR in the series. --- doc/sphinxext/jupyterlite_lite_renderer.py | 590 ++++++++++++++ doc/sphinxext/jupyterlite_setup_cell.py | 896 +++++++++++++++++++++ 2 files changed, 1486 insertions(+) create mode 100644 doc/sphinxext/jupyterlite_lite_renderer.py create mode 100644 doc/sphinxext/jupyterlite_setup_cell.py diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py new file mode 100644 index 00000000000..dc73be80743 --- /dev/null +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -0,0 +1,590 @@ +"""A pyvista-js drawing backend for MNE's 3D renderer, for JupyterLite. + +MNE's 3D functions (``plot_alignment``, ``plot_bem``, ``plot_sparse_source_estimates``, +``SourceSpaces.plot``, ...) all build their figure the same way: they do their own +geometry and coordinate-frame work in numpy, then hand the result to a renderer +obtained from ``mne.viz.backends.renderer._get_renderer``. Only that last step needs +VTK, and VTK cannot load in WebAssembly. + +So instead of reimplementing those functions one by one, this module supplies a +renderer that draws with pyvista-js (vtk.js) and patches the factory, along with the +``renderer.backend`` global that ``set_3d_view`` and the other scene-level helpers +read directly. MNE then does all of the transform math itself, which matters +because getting a head/MRI/device transform subtly wrong produces a +plausible-looking picture with the sensors in the wrong place, and several of these +tutorials are specifically *about* coordinate alignment. + +What is supported: meshes, surfaces, spheres, tubes and glyphs, enough for the +static figures the docs render. What is not: the interactive ``Brain`` time viewer, +which additionally needs dock widgets and toolbars, and scalar colormaps, which +pyvista-js 0.15 does not have (scalars fall back to a solid color). + +The source is kept as a string because it has to run inside the browser kernel; it +is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs +build prepends to each JupyterLite notebook. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +LITE_RENDERER_CELL = r''' +# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- +# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own +# geometry and coordinate-frame work and only the drawing is replaced. +def _lite_view_vector(azimuth): + """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" + _a = float(azimuth) % 360.0 + if 45 <= _a < 135: + return (0.0, -1.0, 0.0) + elif 135 <= _a < 225: + return (1.0, 0.0, 0.0) + elif 225 <= _a < 315: + return (0.0, 1.0, 0.0) + return (-1.0, 0.0, 0.0) + + +def _lite_set_view(plotter, azimuth): + """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" + if azimuth is None: + return None + try: + plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + return None + + +# Every scene a notebook drew used to stay live for the kernel's lifetime, +# because the close helpers on _LiteBackend were no-ops. Track the plotters +# weakly -- so they stay collectable -- and give close_all something to free. +_lite_live_plotters = [] + + +def _lite_release_plotter(plotter, close=True): + """Hand back a plotter's meshes, JS arrays and GPU buffers. + + ``clear()`` empties the actor list, which is where the geometry is held, + so that is what frees the memory. ``close=False`` additionally says not to + tear the render window down -- what trimming an older scene wants, since + the notebook has already drawn it. pyvista-js 0.15 implements neither + ``deep_clean`` nor ``close``, so today the two paths do the same thing; + the flag keeps the intent right if that changes. + """ + import gc as _gc + if plotter is None: + return None + for _i in range(len(_lite_live_plotters) - 1, -1, -1): + _p = _lite_live_plotters[_i]() + if _p is None or _p is plotter: + del _lite_live_plotters[_i] + # pyvista-js is someone else's surface, so use whichever teardown of these + # it actually implements + _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") + for _name in _names: + _fn = getattr(plotter, _name, None) + if _fn is not None: + try: + _fn() + except Exception: + pass + _gc.collect() + return None + + +# Each live scene holds its meshes in the WASM heap, a copy of them in JS and +# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without +# a cap they all stay: 20_source_alignment builds six, which is enough to run +# the tab out of memory. Keep the newest few and give the rest their geometry +# back as new ones arrive -- scrolling back shows an empty canvas, which is a +# far better outcome than losing the page. +_LITE_MAX_LIVE_SCENES = 2 + + +def _lite_trim_live_plotters(): + """Release everything but the most recent scenes.""" + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _p = _lite_live_plotters[0]() + if _p is None: + _lite_live_plotters.pop(0) + else: + # also drops it from the registry, so this terminates + _lite_release_plotter(_p, close=False) + return None + + +class _LiteRenderer: + """Minimal MNE 3D renderer backed by pyvista-js.""" + + def __init__(self, *args, **kwargs): + import numpy as _np + import pyvista_js as _pv + self._np = _np + self._pv = _pv + # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite + # into a scene the notebook already made, so draw into that plotter + # rather than opening a second one and splitting the picture in two. + # plot_alignment passes it positionally and create_3d_figure by name, + # and `fig` is _PyVistaRenderer's first argument, so accept both. + _fig = args[0] if args else kwargs.get("fig", None) + if _fig is not None and hasattr(_fig, "add_mesh"): + self.plotter = _fig + return + self.plotter = _pv.Plotter() + import weakref as _weakref + _lite_live_plotters.append(_weakref.ref(self.plotter)) + # trim AFTER appending, so the scene being built is never the one freed + _lite_trim_live_plotters() + _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) + try: + self.plotter.background_color = self._rgb(_bg) + except Exception: + pass + # even lighting, so a surface is not black when rotated + for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)): + try: + self.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)) + except Exception: + pass + + # -- helpers ------------------------------------------------------------ + def _rgb(self, color): + """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" + if color is None: + return (0.5, 0.5, 0.5) + from matplotlib.colors import to_rgb as _to_rgb + if isinstance(color, str): + return _to_rgb(color) + _c = self._np.asarray(color, dtype=float).ravel()[:3] + if _c.size < 3: + return (0.5, 0.5, 0.5) + if _c.max() > 1.0: # 0-255 form + _c = _c / 255.0 + return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) + + def _faces(self, tris): + _np = self._np + _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) + return _np.hstack([ + _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() + + def _subdivide(self, rr, tris): + """One level of midpoint subdivision, sharing the new edge vertices.""" + _np = self._np + _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] + _mid = {} + _out = [] + for _a, _b, _c in _np.asarray(tris, dtype=int): + _m = [] + for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): + _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) + if _k not in _mid: + _mid[_k] = len(_rr) + _rr.append(tuple((_np.asarray(_rr[_p]) + + _np.asarray(_rr[_q])) / 2.0)) + _m.append(_mid[_k]) + _ab, _bc, _ca = _m + _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], + [_ab, _bc, _ca]] + return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) + + def _glyph_template(self, kind, radius=None, height=None, center=None, + resolution=None, **kwargs): + """Return (rr, tris) for a glyph template, oriented along +x. + + pyvista-js's Sphere/Cylinder are parametric primitives with no + triangle list, so build the templates here. ``_tile`` then stamps one + of these at every position and merges the result, which is what keeps + these cheap -- the copies share a single mesh and a single actor. + + Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so + the browser draws the markers at the size the rendered docs do. + """ + _np = self._np + if kind in ("sphere", "oct"): + _r = 0.5 if radius is None else float(radius) + rr = _np.array([[1.0, 0, 0], [-1.0, 0, 0], [0, 1.0, 0], + [0, -1.0, 0], [0, 0, 1.0], [0, 0, -1.0]], float) + tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], + [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int) + # "oct" is an octahedron on purpose -- that is what _pyvista.py + # hands the glyph filter. A "sphere" has to look round, though: + # fiducials and dig points are drawn with it, so subdivide onto the + # unit sphere to land near the reference's 8x8 sphere (58 verts). + if kind == "sphere": + for _ in range(2): + rr, tris = self._subdivide(rr, tris) + rr /= _np.linalg.norm(rr, axis=1)[:, None] + return rr * _r, tris + if kind == "cone": + # apex along +x so the glyph filter's orientation applies, matching + # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height + _r = 0.15 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), + _r * _np.sin(_ang)]) + rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base + return rr, _np.asarray(tris, int) + # cylinder along +x, matching _cylinder_geom's convention + _r = 0.1 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _c = _np.zeros(3) if center is None else _np.asarray(center, float) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), + _r * _np.sin(_ang)]) + _back = _ring + _np.array([-_h / 2.0, 0, 0]) + _front = _ring + _np.array([_h / 2.0, 0, 0]) + rr = _np.vstack([_back, _front, + [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall + tris += [[2 * _n, _j, _i]] # back cap + tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap + return rr, _np.asarray(tris, int) + + def _add(self, points, tris, color, opacity=1.0): + """Draw a mesh and return MNE's (actor, mesh) pair. + + ``opacity=None`` means "renderer default" in MNE's renderer API, which + for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every + drawing method here funnels through this, so translating it once covers + all of them. + """ + _np = self._np + _pd = self._pv.PolyData( + points=_np.asarray(points, dtype=_np.float32), + faces=self._faces(tris)) + _actor = self.plotter.add_mesh( + _pd, color=self._rgb(color), + opacity=1.0 if opacity is None else float(opacity), + smooth_shading=True) + return _actor, _pd + + def _rots_from_dirs(self, dirs): + """Rotations carrying +x onto each direction, as the glyphs assume.""" + _np = self._np + from mne.transforms import _find_vector_rotation as _fvr + _x = _np.array([1.0, 0.0, 0.0]) + return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) + + def _tile(self, rr, tris, positions, scales=None, rots=None, + axis_scales=None): + """Stamp one template mesh at many positions as a single mesh. + + ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes + every copy into one mesh and adds it once. Doing this per position + instead means an oct-6 source space becomes 8196 meshes and 8196 + actors, which is enough to run the browser tab out of memory. + """ + _np = self._np + _rr = _np.asarray(rr, dtype=float) + _tris = _np.asarray(tris, dtype=int) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + _pts = _np.repeat(_rr[None, :, :], _n, axis=0) + if axis_scales is not None: + # tubes span a given length without fattening, so scale the + # template's axis alone + _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) + _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] + if rots is not None: + _ra = _np.asarray(rots, dtype=float) + _pts = _np.einsum( + 'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts) + _pts += _pos[:, None, :] + _off = (_np.arange(_n) * len(_rr))[:, None, None] + return (_pts.reshape(-1, 3), + (_tris[None, :, :] + _off).reshape(-1, 3)) + + # -- drawing ------------------------------------------------------------ + def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): + _np = self._np + _pts = _np.column_stack([_np.asarray(x).ravel(), + _np.asarray(y).ravel(), + _np.asarray(z).ravel()]) + return self._add(_pts, triangles, color, opacity) + + def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): + return self._add(surface["rr"], surface["tris"], color, opacity) + + def sphere(self, center, color=None, scale=1.0, opacity=1.0, + resolution=8, backface_culling=False, radius=None, **kwargs): + _np = self._np + _c = _np.atleast_2d(_np.asarray(center, dtype=float)) + if not len(_c): + return None, None + _r = float(radius if radius is not None else scale) + _rr, _tris = self._glyph_template("sphere", radius=_r, + resolution=resolution) + _pts, _faces = self._tile(_rr, _tris, _c) + return self._add(_pts, _faces, color, opacity) + + def tube(self, origin, destination, radius=0.001, color=None, *args, + **kwargs): + _np = self._np + _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] + _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] + _n = min(len(_o), len(_d)) + if not _n: + return None, None + _vec = _d[:_n] - _o[:_n] + _len = _np.linalg.norm(_vec, axis=1) + _keep = _len > 0 + if not _keep.any(): + return None, None + _vec, _len = _vec[_keep], _len[_keep] + _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 + # one unit-height template stretched to each segment, merged into a + # single mesh rather than a cylinder primitive per segment + _rr, _tris = self._glyph_template("cylinder", radius=float(radius), + height=1.0) + _pts, _faces = self._tile( + _rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]), + axis_scales=_len) + return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) + + def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow", + opacity=1.0, *, glyph_height=None, glyph_center=None, + glyph_resolution=None, glyph_radius=0.15, + solid_transform=None, **kwargs): + """Draw one merged glyph mesh, the way the glyph filter would. + + ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy + at every point into one mesh, and adds that once. Drawing a primitive + per point instead is what made ``20_source_alignment`` -- an oct-6 + source space, so 8196 glyphs, twice -- exhaust the browser tab. + """ + _np = self._np + _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (x, y, z)) + _ctr = _np.column_stack([_x, _y, _z]) + _n = len(_ctr) + if not _n: + return None, None + _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 + _i = _np.arange(_n) + _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (u, v, w)) + _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], + _w[_i % len(_w)]]) + _norm = _np.linalg.norm(_dirs, axis=1) + _flat = _norm == 0 + _dirs[_flat] = (1.0, 0.0, 0.0) + _norm[_flat] = 1.0 + _dirs = _dirs / _norm[:, None] + # the same templates _pyvista.py feeds the filter; `scale` then plays + # the part its `factor` does + if mode == "oct": + # vtkPlatonicSolidSource puts its octahedron on the unit + # circumsphere, and the MRI fiducials get their real size from + # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` + _kind, _tkw = "oct", dict(radius=1.0) + elif mode == "sphere": + _kind, _tkw = "sphere", dict(radius=0.5) + elif mode == "cylinder": + _kind = "cylinder" + _tkw = dict(radius=glyph_radius, height=glyph_height, + center=glyph_center, resolution=glyph_resolution) + else: # arrow / cone / 2darrow + _kind = "cone" + _tkw = dict(radius=glyph_radius, height=glyph_height, + resolution=glyph_resolution) + _rr, _tris = self._glyph_template(_kind, **_tkw) + if solid_transform is not None: + # _pyvista.py transforms the template before glyphing, and this is + # where the fiducial markers get their size and 45 deg roll + _st = _np.asarray(solid_transform, dtype=float) + _rr = _rr @ _st[:3, :3].T + _st[:3, 3] + _rots = (None if mode in ("sphere", "oct") + else self._rots_from_dirs(_dirs)) + _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) + return self._add(_pts, _faces, color, opacity) + + def instanced_mesh(self, rr, tris, positions, quats=None, colors=None, + scales=None, opacity=1.0, *args, **kwargs): + """Stamp the template at every position, merged per distinct color. + + Rotate with MNE's own quaternion helper so oriented glyphs (EEG + cylinders) point the way MNE intended rather than all along +x. + pyvista-js has no per-vertex color, so instances are grouped by the + color they asked for and each group becomes one mesh -- a handful of + actors for a sensor array instead of one per sensor. + """ + _np = self._np + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + if not _n: + return None, None + _rot = None + if quats is not None: + from mne.transforms import quat_to_rot as _q2r + _rot = _np.asarray(_q2r(_np.atleast_2d( + _np.asarray(quats, dtype=float))), dtype=float) + _idx = _np.arange(_n) + if colors is not None and _np.ndim(colors) > 1: + _ca = _np.asarray(colors) + _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, + return_inverse=True) + _inv = _np.asarray(_inv).ravel() + _groups = [(_uniq[_k], _idx[_inv == _k]) + for _k in range(len(_uniq))] + else: + _groups = [(colors, _idx)] + _out = (None, None) + for _col, _sel in _groups: + _sc = None + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _sc = _sa[_sel % len(_sa)] + _rt = None if _rot is None else _rot[_sel % len(_rot)] + _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, + rots=_rt) + _out = self._add(_pts, _faces, _col, opacity) + return _out + + # -- things the static docs do not need --------------------------------- + def contour(self, *args, **kwargs): + # pyvista-js 0.15 has no scalar contouring; callers unpack a pair + return None, None + + def text2d(self, *args, **kwargs): + return None + + def text3d(self, *args, **kwargs): + return None + + def scalarbar(self, *args, **kwargs): + return None + + def legend(self, *args, **kwargs): + return None + + def subplot(self, *args, **kwargs): + return None + + def set_interaction(self, *args, **kwargs): + return None + + def remove_mesh(self, *args, **kwargs): + return None + + def project(self, xyz, ch_names): + return self._np.asarray(xyz, dtype=float)[:, :2] + + def screenshot(self, mode="rgb", filename=None, **kwargs): + return self._np.zeros((2, 2, 3), dtype="uint8") + + def close(self): + return None + + def _update(self, *args, **kwargs): + return None + + def _process_events(self, *args, **kwargs): + return None + + def _enable_time_interaction(self, *args, **kwargs): + # the figures are static here; there is no time slider to wire up + return None + + def _window_close_connect(self, *args, **kwargs): + return None + + def _window_set_cursor(self, *args, **kwargs): + return None + + def get_camera(self, *args, **kwargs): + return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) + + def set_camera(self, azimuth=None, elevation=None, distance=None, + focalpoint=None, roll=None, *args, **kwargs): + # pyvista-js has no azimuth/elevation camera; approximate the common + # views and otherwise leave the default. + return _lite_set_view(self.plotter, azimuth) + + @property + def figure(self): + """The scene, under the name the tutorials reach for. + + ``_PyVistaRenderer`` hands out one object as both ``.figure`` and + ``.scene()``; ``20_source_alignment`` builds a renderer itself with + ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` + to ``set_3d_view``, so the two have to stay the same thing here too. + """ + return self.plotter + + def scene(self): + return self.plotter + + def show(self): + try: + self.plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js render failed: " + repr(_e)) + return None + + +def _lite_get_renderer(*args, **kwargs): + return _LiteRenderer(*args, **kwargs) + + +class _LiteBackend: + """Stand-in for the module MNE imports into ``renderer.backend``. + + ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level + functions that reach for that global directly instead of going through + ``_get_renderer``, so replacing the factory alone leaves them calling into + ``None``. The figure they are handed is the pyvista-js plotter that + ``_LiteRenderer.scene`` returns. + """ + + def _set_3d_view(self, figure, azimuth=None, elevation=None, + focalpoint=None, distance=None, roll=None): + return _lite_set_view(figure, azimuth) + + def _set_3d_title(self, figure, title, size=40, color="white", + position="upper_left"): + return None + + def _close_3d_figure(self, figure): + _lite_release_plotter(figure) + return None + + def _close_all(self): + # the registry holds weak references, so deref before releasing -- + # handing the ref itself to _lite_release_plotter matches nothing and + # never shortens the list + while _lite_live_plotters: + _p = _lite_live_plotters[-1]() + if _p is None: + _lite_live_plotters.pop() + else: + _lite_release_plotter(_p) + return None + + +try: + import mne.viz.backends.renderer as _mne_rend + _mne_rend._get_renderer = _lite_get_renderer + _mne_rend.backend = _LiteBackend() + # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and + # importing _qt, which would overwrite the stub above on its way to failing + _mne_rend.MNE_3D_BACKEND = "notebook" +except Exception as _e: + print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) +''' diff --git a/doc/sphinxext/jupyterlite_setup_cell.py b/doc/sphinxext/jupyterlite_setup_cell.py new file mode 100644 index 00000000000..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 c20eae74c50ee3f1f7134de05ba004cd865018c2 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 8 Aug 2026 15:41:58 -0400 Subject: [PATCH 2/2] DOC: add the changelog entry for the browser runtime --- doc/changes/dev/14144.other.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14144.other.rst diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.other.rst new file mode 100644 index 00000000000..f73ac46ccf2 --- /dev/null +++ b/doc/changes/dev/14144.other.rst @@ -0,0 +1 @@ +Add the browser-side runtime for the JupyterLite documentation: the notebook setup cell and a vtk.js backend for the 3D renderer, by `Natneal B`_.