Skip to content

Commit a5b4a3c

Browse files
FIX: draw glyphs as one merged mesh, not one per point [circle full]
An oct-6 source space meant 8196 meshes and 8196 actors in a single scene, which ran the browser tab out of memory. Also honor glyph_resolution/height and solid_transform, so the MRI fiducials come out 5 mm rather than a metre.
1 parent b7b4319 commit a5b4a3c

1 file changed

Lines changed: 245 additions & 77 deletions

File tree

doc/sphinxext/jupyterlite_lite_renderer.py

Lines changed: 245 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,34 @@ def _lite_set_view(plotter, azimuth):
5454
return None
5555
5656
57+
# Every scene a notebook drew used to stay live for the kernel's lifetime,
58+
# because the close helpers on _LiteBackend were no-ops. Track the plotters
59+
# weakly -- so they stay collectable -- and give close_all something to free.
60+
_lite_live_plotters = []
61+
62+
63+
def _lite_release_plotter(plotter):
64+
"""Hand back a plotter's meshes, JS arrays and GPU buffers."""
65+
import gc as _gc
66+
if plotter is None:
67+
return None
68+
for _i in range(len(_lite_live_plotters) - 1, -1, -1):
69+
_p = _lite_live_plotters[_i]()
70+
if _p is None or _p is plotter:
71+
del _lite_live_plotters[_i]
72+
# pyvista-js is someone else's surface, so use whichever teardown of these
73+
# it actually implements
74+
for _name in ("clear", "deep_clean", "close"):
75+
_fn = getattr(plotter, _name, None)
76+
if _fn is not None:
77+
try:
78+
_fn()
79+
except Exception:
80+
pass
81+
_gc.collect()
82+
return None
83+
84+
5785
class _LiteRenderer:
5886
"""Minimal MNE 3D renderer backed by pyvista-js."""
5987
@@ -63,6 +91,8 @@ def __init__(self, *args, **kwargs):
6391
self._np = _np
6492
self._pv = _pv
6593
self.plotter = _pv.Plotter()
94+
import weakref as _weakref
95+
_lite_live_plotters.append(_weakref.ref(self.plotter))
6696
_bg = kwargs.get("bgcolor", kwargs.get("background_color", "black"))
6797
try:
6898
self.plotter.background_color = self._rgb(_bg)
@@ -99,23 +129,69 @@ def _faces(self, tris):
99129
return _np.hstack([
100130
_np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel()
101131
132+
def _subdivide(self, rr, tris):
133+
"""One level of midpoint subdivision, sharing the new edge vertices."""
134+
_np = self._np
135+
_rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)]
136+
_mid = {}
137+
_out = []
138+
for _a, _b, _c in _np.asarray(tris, dtype=int):
139+
_m = []
140+
for _p, _q in ((_a, _b), (_b, _c), (_c, _a)):
141+
_k = (min(int(_p), int(_q)), max(int(_p), int(_q)))
142+
if _k not in _mid:
143+
_mid[_k] = len(_rr)
144+
_rr.append(tuple((_np.asarray(_rr[_p])
145+
+ _np.asarray(_rr[_q])) / 2.0))
146+
_m.append(_mid[_k])
147+
_ab, _bc, _ca = _m
148+
_out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c],
149+
[_ab, _bc, _ca]]
150+
return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int)
151+
102152
def _glyph_template(self, kind, radius=None, height=None, center=None,
103153
resolution=None, **kwargs):
104-
"""Return (rr, tris) for instanced_mesh, oriented along +x.
154+
"""Return (rr, tris) for a glyph template, oriented along +x.
105155
106156
pyvista-js's Sphere/Cylinder are parametric primitives with no
107-
triangle list, so build the templates here. These are markers a few
108-
millimetres across, so keep them low-poly -- every instance is a
109-
separate mesh and the WASM heap is not large.
157+
triangle list, so build the templates here. ``_tile`` then stamps one
158+
of these at every position and merges the result, which is what keeps
159+
these cheap -- the copies share a single mesh and a single actor.
160+
161+
Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so
162+
the browser draws the markers at the size the rendered docs do.
110163
"""
111164
_np = self._np
112-
if kind == "sphere":
165+
if kind in ("sphere", "oct"):
113166
_r = 0.5 if radius is None else float(radius)
114-
rr = _np.array([[_r, 0, 0], [-_r, 0, 0], [0, _r, 0],
115-
[0, -_r, 0], [0, 0, _r], [0, 0, -_r]], float)
167+
rr = _np.array([[1.0, 0, 0], [-1.0, 0, 0], [0, 1.0, 0],
168+
[0, -1.0, 0], [0, 0, 1.0], [0, 0, -1.0]], float)
116169
tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4],
117170
[2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int)
118-
return rr, tris
171+
# "oct" is an octahedron on purpose -- that is what _pyvista.py
172+
# hands the glyph filter. A "sphere" has to look round, though:
173+
# fiducials and dig points are drawn with it, so subdivide onto the
174+
# unit sphere to land near the reference's 8x8 sphere (58 verts).
175+
if kind == "sphere":
176+
for _ in range(2):
177+
rr, tris = self._subdivide(rr, tris)
178+
rr /= _np.linalg.norm(rr, axis=1)[:, None]
179+
return rr * _r, tris
180+
if kind == "cone":
181+
# apex along +x so the glyph filter's orientation applies, matching
182+
# pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height
183+
_r = 0.15 if radius is None else float(radius)
184+
_h = 1.0 if height is None else float(height)
185+
_n = 8 if not resolution else max(3, int(resolution) // 2)
186+
_ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False)
187+
_ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang),
188+
_r * _np.sin(_ang)])
189+
rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]])
190+
tris = []
191+
for _i in range(_n):
192+
_j = (_i + 1) % _n
193+
tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base
194+
return rr, _np.asarray(tris, int)
119195
# cylinder along +x, matching _cylinder_geom's convention
120196
_r = 0.1 if radius is None else float(radius)
121197
_h = 1.0 if height is None else float(height)
@@ -147,6 +223,45 @@ def _add(self, points, tris, color, opacity=1.0):
147223
smooth_shading=True)
148224
return _actor, _pd
149225
226+
def _rots_from_dirs(self, dirs):
227+
"""Rotations carrying +x onto each direction, as the glyphs assume."""
228+
_np = self._np
229+
from mne.transforms import _find_vector_rotation as _fvr
230+
_x = _np.array([1.0, 0.0, 0.0])
231+
return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float)
232+
233+
def _tile(self, rr, tris, positions, scales=None, rots=None,
234+
axis_scales=None):
235+
"""Stamp one template mesh at many positions as a single mesh.
236+
237+
``_pyvista.py`` hands its template to VTK's glyph filter, which bakes
238+
every copy into one mesh and adds it once. Doing this per position
239+
instead means an oct-6 source space becomes 8196 meshes and 8196
240+
actors, which is enough to run the browser tab out of memory.
241+
"""
242+
_np = self._np
243+
_rr = _np.asarray(rr, dtype=float)
244+
_tris = _np.asarray(tris, dtype=int)
245+
_pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3]
246+
_n = len(_pos)
247+
_pts = _np.repeat(_rr[None, :, :], _n, axis=0)
248+
if axis_scales is not None:
249+
# tubes span a given length without fattening, so scale the
250+
# template's axis alone
251+
_ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float))
252+
_pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None]
253+
if scales is not None:
254+
_sa = _np.atleast_1d(_np.asarray(scales, dtype=float))
255+
_pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None]
256+
if rots is not None:
257+
_ra = _np.asarray(rots, dtype=float)
258+
_pts = _np.einsum(
259+
'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts)
260+
_pts += _pos[:, None, :]
261+
_off = (_np.arange(_n) * len(_rr))[:, None, None]
262+
return (_pts.reshape(-1, 3),
263+
(_tris[None, :, :] + _off).reshape(-1, 3))
264+
150265
# -- drawing ------------------------------------------------------------
151266
def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs):
152267
_np = self._np
@@ -162,92 +277,135 @@ def sphere(self, center, color=None, scale=1.0, opacity=1.0,
162277
resolution=8, backface_culling=False, radius=None, **kwargs):
163278
_np = self._np
164279
_c = _np.atleast_2d(_np.asarray(center, dtype=float))
280+
if not len(_c):
281+
return None, None
165282
_r = float(radius if radius is not None else scale)
166-
_actor = _mesh = None
167-
for _p in _c:
168-
_mesh = self._pv.Sphere(
169-
radius=_r, center=tuple(float(_q) for _q in _p[:3]))
170-
_actor = self.plotter.add_mesh(
171-
_mesh, color=self._rgb(color), opacity=float(opacity),
172-
smooth_shading=True)
173-
return _actor, _mesh
174-
175-
def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs):
283+
_rr, _tris = self._glyph_template("sphere", radius=_r,
284+
resolution=resolution)
285+
_pts, _faces = self._tile(_rr, _tris, _c)
286+
return self._add(_pts, _faces, color, opacity)
287+
288+
def tube(self, origin, destination, radius=0.001, color=None, *args,
289+
**kwargs):
176290
_np = self._np
177-
_o = _np.atleast_2d(_np.asarray(origin, dtype=float))
178-
_d = _np.atleast_2d(_np.asarray(destination, dtype=float))
179-
_actor = _mesh = None
180-
for _a, _b in zip(_o, _d):
181-
_vec = _b[:3] - _a[:3]
182-
_len = float(_np.linalg.norm(_vec))
183-
if _len == 0.0:
184-
continue
185-
_mesh = self._pv.Cylinder(
186-
center=tuple(float(_q) for _q in (_a[:3] + _b[:3]) / 2.0),
187-
direction=tuple(float(_q) for _q in _vec / _len),
188-
radius=float(radius), height=_len)
189-
_actor = self.plotter.add_mesh(
190-
_mesh, color=self._rgb(color), smooth_shading=True)
191-
return _actor, _mesh
291+
_o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3]
292+
_d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3]
293+
_n = min(len(_o), len(_d))
294+
if not _n:
295+
return None, None
296+
_vec = _d[:_n] - _o[:_n]
297+
_len = _np.linalg.norm(_vec, axis=1)
298+
_keep = _len > 0
299+
if not _keep.any():
300+
return None, None
301+
_vec, _len = _vec[_keep], _len[_keep]
302+
_ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0
303+
# one unit-height template stretched to each segment, merged into a
304+
# single mesh rather than a cylinder primitive per segment
305+
_rr, _tris = self._glyph_template("cylinder", radius=float(radius),
306+
height=1.0)
307+
_pts, _faces = self._tile(
308+
_rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]),
309+
axis_scales=_len)
310+
return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0))
192311
193312
def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow",
194-
opacity=1.0, *args, **kwargs):
313+
opacity=1.0, *, glyph_height=None, glyph_center=None,
314+
glyph_resolution=None, glyph_radius=0.15,
315+
solid_transform=None, **kwargs):
316+
"""Draw one merged glyph mesh, the way the glyph filter would.
317+
318+
``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy
319+
at every point into one mesh, and adds that once. Drawing a primitive
320+
per point instead is what made ``20_source_alignment`` -- an oct-6
321+
source space, so 8196 glyphs, twice -- exhaust the browser tab.
322+
"""
195323
_np = self._np
196324
_x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float))
197325
for _q in (x, y, z))
326+
_ctr = _np.column_stack([_x, _y, _z])
327+
_n = len(_ctr)
328+
if not _n:
329+
return None, None
330+
_s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0
331+
_i = _np.arange(_n)
198332
_u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float))
199333
for _q in (u, v, w))
200-
_s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0
201-
_actor = _g = None
202-
for _i in range(len(_x)):
203-
_ctr = (float(_x[_i]), float(_y[_i]), float(_z[_i]))
204-
_dir = (float(_u[_i % len(_u)]), float(_v[_i % len(_v)]),
205-
float(_w[_i % len(_w)]))
206-
if _np.linalg.norm(_dir) == 0.0:
207-
_dir = (0.0, 0.0, 1.0)
208-
if mode == "sphere":
209-
_g = self._pv.Sphere(radius=_s / 2.0, center=_ctr)
210-
elif mode in ("cylinder", "oct"):
211-
_g = self._pv.Cylinder(center=_ctr, direction=_dir,
212-
radius=_s / 4.0, height=_s)
213-
else: # arrow / cone / 2darrow
214-
_g = self._pv.Cone(center=_ctr, direction=_dir,
215-
height=_s, radius=_s / 2.0)
216-
_actor = self.plotter.add_mesh(
217-
_g, color=self._rgb(color), opacity=float(opacity),
218-
smooth_shading=True)
219-
return _actor, _g
334+
_dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)],
335+
_w[_i % len(_w)]])
336+
_norm = _np.linalg.norm(_dirs, axis=1)
337+
_flat = _norm == 0
338+
_dirs[_flat] = (1.0, 0.0, 0.0)
339+
_norm[_flat] = 1.0
340+
_dirs = _dirs / _norm[:, None]
341+
# the same templates _pyvista.py feeds the filter; `scale` then plays
342+
# the part its `factor` does
343+
if mode == "oct":
344+
# vtkPlatonicSolidSource puts its octahedron on the unit
345+
# circumsphere, and the MRI fiducials get their real size from
346+
# solid_transform (mri_fid_scale, 5 mm) rather than from `scale`
347+
_kind, _tkw = "oct", dict(radius=1.0)
348+
elif mode == "sphere":
349+
_kind, _tkw = "sphere", dict(radius=0.5)
350+
elif mode == "cylinder":
351+
_kind = "cylinder"
352+
_tkw = dict(radius=glyph_radius, height=glyph_height,
353+
center=glyph_center, resolution=glyph_resolution)
354+
else: # arrow / cone / 2darrow
355+
_kind = "cone"
356+
_tkw = dict(radius=glyph_radius, height=glyph_height,
357+
resolution=glyph_resolution)
358+
_rr, _tris = self._glyph_template(_kind, **_tkw)
359+
if solid_transform is not None:
360+
# _pyvista.py transforms the template before glyphing, and this is
361+
# where the fiducial markers get their size and 45 deg roll
362+
_st = _np.asarray(solid_transform, dtype=float)
363+
_rr = _rr @ _st[:3, :3].T + _st[:3, 3]
364+
_rots = (None if mode in ("sphere", "oct")
365+
else self._rots_from_dirs(_dirs))
366+
_pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots)
367+
return self._add(_pts, _faces, color, opacity)
220368
221369
def instanced_mesh(self, rr, tris, positions, quats=None, colors=None,
222370
scales=None, opacity=1.0, *args, **kwargs):
223-
# one copy of the template per position; rotate with MNE's own
224-
# quaternion helper so oriented glyphs (EEG cylinders) point the way
225-
# MNE intended rather than all along +x.
371+
"""Stamp the template at every position, merged per distinct color.
372+
373+
Rotate with MNE's own quaternion helper so oriented glyphs (EEG
374+
cylinders) point the way MNE intended rather than all along +x.
375+
pyvista-js has no per-vertex color, so instances are grouped by the
376+
color they asked for and each group becomes one mesh -- a handful of
377+
actors for a sensor array instead of one per sensor.
378+
"""
226379
_np = self._np
227-
_rr = _np.asarray(rr, dtype=float)
228-
_pos = _np.atleast_2d(_np.asarray(positions, dtype=float))
229-
_quats = None if quats is None else _np.atleast_2d(
230-
_np.asarray(quats, dtype=float))
380+
_pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3]
381+
_n = len(_pos)
382+
if not _n:
383+
return None, None
231384
_rot = None
232-
if _quats is not None:
233-
try:
234-
from mne.transforms import quat_to_rot as _q2r
235-
_rot = _q2r(_quats)
236-
except Exception:
237-
_rot = None
385+
if quats is not None:
386+
from mne.transforms import quat_to_rot as _q2r
387+
_rot = _np.asarray(_q2r(_np.atleast_2d(
388+
_np.asarray(quats, dtype=float))), dtype=float)
389+
_idx = _np.arange(_n)
390+
if colors is not None and _np.ndim(colors) > 1:
391+
_ca = _np.asarray(colors)
392+
_uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0,
393+
return_inverse=True)
394+
_inv = _np.asarray(_inv).ravel()
395+
_groups = [(_uniq[_k], _idx[_inv == _k])
396+
for _k in range(len(_uniq))]
397+
else:
398+
_groups = [(colors, _idx)]
238399
_out = (None, None)
239-
for _i, _p in enumerate(_pos):
240-
_s = 1.0
400+
for _col, _sel in _groups:
401+
_sc = None
241402
if scales is not None:
242403
_sa = _np.atleast_1d(_np.asarray(scales, dtype=float))
243-
_s = float(_sa[_i % len(_sa)])
244-
_col = colors
245-
if colors is not None and _np.ndim(colors) > 1:
246-
_col = _np.asarray(colors)[_i % len(colors)]
247-
_pts = _rr * _s
248-
if _rot is not None:
249-
_pts = _pts @ _np.asarray(_rot[_i % len(_rot)]).T
250-
_out = self._add(_pts + _p[:3], tris, _col, opacity)
404+
_sc = _sa[_sel % len(_sa)]
405+
_rt = None if _rot is None else _rot[_sel % len(_rot)]
406+
_pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc,
407+
rots=_rt)
408+
_out = self._add(_pts, _faces, _col, opacity)
251409
return _out
252410
253411
# -- things the static docs do not need ---------------------------------
@@ -344,9 +502,19 @@ def _set_3d_title(self, figure, title, size=40, color="white",
344502
return None
345503
346504
def _close_3d_figure(self, figure):
505+
_lite_release_plotter(figure)
347506
return None
348507
349508
def _close_all(self):
509+
# the registry holds weak references, so deref before releasing --
510+
# handing the ref itself to _lite_release_plotter matches nothing and
511+
# never shortens the list
512+
while _lite_live_plotters:
513+
_p = _lite_live_plotters[-1]()
514+
if _p is None:
515+
_lite_live_plotters.pop()
516+
else:
517+
_lite_release_plotter(_p)
350518
return None
351519
352520

0 commit comments

Comments
 (0)