Skip to content

Commit d828df4

Browse files
authored
ENH: Speed up and clean up 3D code (#14016)
Looks good to me! Now I have a path to coloring channels. Thanks @larsoner!
1 parent 11605a7 commit d828df4

22 files changed

Lines changed: 599 additions & 442 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:class:`mne.viz.Brain`'s ``silhouette`` option now accepts a spacing string (e.g. ``"ico5"``, now the default) to decimate using the same vertex-picking as :func:`mne.setup_source_space` instead of a generic mesh decimation. :func:`mne.viz.plot_alignment` and :class:`mne.viz.Brain` should also be faster when plotting many sensors (e.g., whole-head MEG systems), by `Eric Larson`_.

doc/sphinxext/mne_doc_utils.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,19 @@
77
import functools
88
import gc
99
import os
10+
import sys
1011
import time
1112
import warnings
1213
from pathlib import Path
1314

1415
import numpy as np
1516
import pyvista
1617
import sphinx.util.logging
18+
from refleak.testing import assert_no_instances
1719
from sphinx.errors import ExtensionError
1820

1921
import mne
20-
from mne.utils import (
21-
_assert_no_instances,
22-
_get_extra_data_path,
23-
sizeof_fmt,
24-
)
22+
from mne.utils import Bunch, _get_extra_data_path, sizeof_fmt
2523
from mne.viz import Brain
2624

2725
sphinx_logger = sphinx.util.logging.getLogger("mne")
@@ -177,6 +175,18 @@ def reset_modules(gallery_conf, fname, when):
177175
neo.io.stimfitio.STFIO_ERR = None
178176
except Exception:
179177
pass
178+
# IPython.core.completer does `import __main__` at import time, permanently
179+
# capturing whatever sys.modules['__main__'] was at that moment. Since SG
180+
# temporarily swaps sys.modules['__main__'] to each example's throwaway
181+
# namespace while it runs, if that import happens to fire during one of
182+
# those windows, it pins that example's globals (and anything reachable
183+
# from them) alive for the rest of the process.
184+
try:
185+
import IPython.core.completer
186+
187+
IPython.core.completer.__main__ = sys.modules["__main__"]
188+
except Exception:
189+
pass
180190
gc.collect()
181191

182192
# Agg does not call close_event so let's clean up on our own :(
@@ -192,19 +202,21 @@ def reset_modules(gallery_conf, fname, when):
192202
# to just test MNEQtBrowser
193203
skips = os.getenv("MNE_SKIP_INSTANCE_ASSERTIONS", "").lower()
194204
prefix = ""
205+
request = Bunch() # just give it something to say "we have done GC already"
206+
request.node = Bunch()
195207
if skips not in ("true", "1", "all"):
196208
prefix = "Clean "
197209
skips = skips.split(",")
198210
if "brain" not in skips:
199-
_assert_no_instances(Brain, when) # calls gc.collect()
211+
assert_no_instances(Brain, when=when, request=request)
200212
if Plotter is not None and "plotter" not in skips:
201-
_assert_no_instances(Plotter, when)
213+
assert_no_instances(Plotter, when=when, request=request)
202214
if BackgroundPlotter is not None and "backgroundplotter" not in skips:
203-
_assert_no_instances(BackgroundPlotter, when)
215+
assert_no_instances(BackgroundPlotter, when=when, request=request)
204216
if vtkPolyData is not None and "vtkpolydata" not in skips:
205-
_assert_no_instances(vtkPolyData, when)
217+
assert_no_instances(vtkPolyData, when=when, request=request)
206218
if "_renderer" not in skips:
207-
_assert_no_instances(_Renderer, when)
219+
assert_no_instances(_Renderer, when=when, request=request)
208220
if MNEQtBrowser is not None and "mneqtbrowser" not in skips:
209221
# Ensure any manual fig.close() events get properly handled
210222
from mne_qt_browser._pg_figure import QApplication
@@ -213,7 +225,7 @@ def reset_modules(gallery_conf, fname, when):
213225
if inst is not None:
214226
for _ in range(2):
215227
inst.processEvents()
216-
_assert_no_instances(MNEQtBrowser, when)
228+
assert_no_instances(MNEQtBrowser, when=when, request=request)
217229
# This will overwrite some Sphinx printing but it's useful
218230
# for memory timestamps
219231
if os.getenv("SG_STAMP_STARTS", "").lower() == "true":

doc/sphinxext/related_software.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
from sphinx.errors import ExtensionError
3030
from sphinx.util.display import status_iterator
3131

32-
# If a package is in MNE-Installers (preferred method), no need to add it here.
33-
# But still add it to doc/sphinxext/related_software.txt!
32+
# 1. If a package is in MNE-Installers (preferred method), no need to add it here.
33+
# But still add it to doc/sphinxext/related_software.txt!
3434

35-
# If it's available on PyPI, add it to this set:
35+
# 2. If it's available on PyPI, add it to this set:
3636
PYPI_PACKAGES = {
3737
"cross-domain-saliency-maps",
3838
"meggie",
@@ -41,7 +41,7 @@
4141
"zuna",
4242
}
4343

44-
# If it's not available on PyPI, add it to this dict:
44+
# 3. If it's not available on PyPI, add it to this dict:
4545
MANUAL_PACKAGES = {
4646
# TODO: These packages are not pip-installable as of 2025/11/19, so we have to
4747
# manually populate them -- should open issues on their package repos.

mne/conftest.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import pytest
2323
from packaging.version import Version
2424
from pytest import StashKey, register_assert_rewrite
25+
from refleak.testing import assert_no_instances, gc_collect_once
2526

2627
# Any `assert` statements in our testing functions should be verbose versions
2728
register_assert_rewrite("mne.utils._testing")
@@ -38,7 +39,6 @@
3839
from mne.stats import cluster_level
3940
from mne.utils import (
4041
Bunch,
41-
_assert_no_instances,
4242
_check_qt_version,
4343
_chmod_rw_R,
4444
_pl,
@@ -628,10 +628,10 @@ def triaxial_evoked(triaxial_raw):
628628

629629

630630
@pytest.fixture
631-
def garbage_collect():
631+
def garbage_collect(request):
632632
"""Garbage collect on exit."""
633633
yield
634-
gc.collect()
634+
gc_collect_once(request)
635635

636636

637637
@pytest.fixture
@@ -687,7 +687,9 @@ def pg_backend(request, garbage_collect):
687687
mne_qt_browser._browser_instances.clear()
688688
if not _test_passed(request):
689689
return
690-
_assert_no_instances(MNEQtBrowser, f"Closure of {request.node.name}")
690+
assert_no_instances(
691+
MNEQtBrowser, f"Closure of {request.node.name}", request=request
692+
)
691693

692694

693695
@pytest.fixture(
@@ -1081,8 +1083,13 @@ def brain_gc(request):
10811083
close_func()
10821084
if not _test_passed(request):
10831085
return
1084-
_assert_no_instances(Brain, "after")
1085-
# Check VTK
1086+
gc_collect_once(request)
1087+
# Brain._instances is a WeakSet populated only when MNE_3D_BACKEND_TESTING
1088+
# is set (see Brain.__init__), so use it instead of a slow gc.get_objects()
1089+
# scan of the whole process to check for lingering Brain instances.
1090+
assert_no_instances(Brain, "after", request=request, objs=list(Brain._instances))
1091+
# Check VTK -- these aren't individually tracked, so we still need a full
1092+
# heap scan here.
10861093
objs = gc.get_objects()
10871094
bad = list()
10881095
for o in objs:

mne/gui/tests/test_coreg.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ def test_coreg_gui_pyvista_basic(tmp_path, monkeypatch, renderer_interactive_pyv
263263
coreg._redraw(verbose="debug")
264264
assert "Drawing meg sensors" in log.getvalue()
265265
assert coreg._actors["helmet"] is not None
266-
assert len(coreg._actors["sensors"]) == 306
266+
# coil surfaces sharing a shape are GPU-instanced into a single
267+
# mesh/actor; this Neuromag sample dataset has 2 distinct MEG coil
268+
# shapes (magnetometer + planar gradiometer)
269+
assert len(coreg._actors["sensors"]) == 2
267270
assert coreg._orient_glyphs
268271
assert coreg._scale_by_distance
269272
assert coreg._mark_inside

mne/surface.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,20 @@ def _tessellate_sphere(mylevel):
12691269
return rr, tris
12701270

12711271

1272+
def _decimate_surface_ico_oct(subject, subjects_dir, hemi, surf, spacing):
1273+
from .source_space._source_space import _check_spacing
1274+
1275+
stype, _, ico_surf, _ = _check_spacing(spacing, verbose=False)
1276+
subjects_dir = Path(subjects_dir)
1277+
surf_fname = subjects_dir / subject / "surf" / f"{hemi}.{surf}"
1278+
dec = _create_surf_spacing(surf_fname, hemi, subject, stype, ico_surf, subjects_dir)
1279+
vertno, use_tris = dec["vertno"], dec["use_tris"]
1280+
lut = np.zeros(dec["np"], int)
1281+
lut[vertno] = np.arange(len(vertno))
1282+
tris = lut[use_tris]
1283+
return vertno, tris
1284+
1285+
12721286
def _create_surf_spacing(surf, hemi, subject, stype, ico_surf, subjects_dir):
12731287
"""Load a surf and use the subdivided icosahedron to get points."""
12741288
# Based on load_source_space_surf_spacing() in load_source_space.c

mne/utils/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,7 @@ def sys_info(
863863
"pytest-qt",
864864
"pytest-rerunfailures",
865865
"pytest-timeout",
866+
"refleak",
866867
"codespell",
867868
"ipython",
868869
"mypy",

mne/utils/misc.py

Lines changed: 5 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
# Copyright the MNE-Python contributors.
66

77
import fnmatch
8-
import gc
98
import hashlib
109
import inspect
1110
import os
@@ -353,80 +352,13 @@ def _file_like(obj):
353352
return all(callable(getattr(obj, name, None)) for name in ("read", "seek"))
354353

355354

356-
def _fullname(obj, *, referent=None):
357-
klass = obj.__class__
358-
module = klass.__module__
359-
name = klass.__qualname__
360-
if module != "builtins":
361-
name = f"{module}.{name}"
362-
if referent is not None:
363-
if isinstance(obj, list | tuple):
364-
for ii, item in enumerate(obj):
365-
if item is referent:
366-
name += f"[{ii}]"
367-
break
368-
elif isinstance(obj, dict):
369-
for key, value in obj.items():
370-
if key is referent:
371-
name += "-key"
372-
break
373-
if value is referent:
374-
name += f"[{key!r}]"
375-
break
376-
return name
377-
378-
355+
# Low-effort backward compat wrapper in case other MNE libraries use this function
379356
def _assert_no_instances(cls, when=""):
357+
from refleak.testing import assert_no_instances
358+
380359
__tracebackhide__ = True
381-
n = 0
382-
ref = list()
383-
gc.collect()
384-
objs = gc.get_objects()
385-
for obj in objs: # e.g., vtkPolyData, Brain, Plotter, etc.
386-
try:
387-
check = isinstance(obj, cls)
388-
except Exception: # such as a weakref
389-
check = False
390-
if check:
391-
if cls.__name__ == "Brain":
392-
ref.append(f"Brain._cleaned = {getattr(obj, '_cleaned', None)}")
393-
rr = gc.get_referrers(obj)
394-
count = 0
395-
for r in rr: # e.g., list, dict, etc. that holds the reference to obj
396-
if (
397-
r is not objs
398-
and r is not globals()
399-
and r is not locals()
400-
and not inspect.isframe(r)
401-
):
402-
name = _fullname(r, referent=obj)
403-
if isinstance(r, list | dict | tuple):
404-
rep = f"len={len(r)}"
405-
r_ = gc.get_referrers(r)
406-
types = list()
407-
for x in r_:
408-
types.append(_fullname(x, referent=r))
409-
types = " / ".join(sorted(types))
410-
rep += f" | {len(r_)} referrers: {types}"
411-
del r_
412-
else:
413-
rep = "repr="
414-
rep += repr(r)[:100].replace("\n", " ")
415-
# If it's a __closure__, get more information
416-
if rep.startswith("<cell at "):
417-
try:
418-
rep += f" ({repr(r.cell_contents)[:100]})"
419-
except Exception:
420-
pass
421-
ref.append(f"{name} with {rep}")
422-
count += 1
423-
del r
424-
del rr
425-
n += count > 0
426-
del obj
427-
del objs
428-
gc.collect()
429-
assert n == 0, f"\n{n} {cls.__name__} @ {when}:\n" + "\n".join(ref)
360+
361+
assert_no_instances(cls, when=when)
430362

431363

432364
def _resource_path(submodule, filename):

mne/utils/tests/test_misc.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,25 @@
1010
import pytest
1111

1212
import mne
13-
from mne.utils import _clean_names, catch_logging, run_subprocess, sizeof_fmt
13+
from mne.utils import (
14+
_assert_no_instances,
15+
_clean_names,
16+
catch_logging,
17+
run_subprocess,
18+
sizeof_fmt,
19+
)
20+
21+
22+
def test_assert_no_instances():
23+
"""Test that our wrapper works."""
24+
25+
class _Foo:
26+
pass
27+
28+
_holder = {"key": _Foo()}
29+
30+
with pytest.raises(AssertionError, match="after closing"):
31+
_assert_no_instances(_Foo, "after closing")
1432

1533

1634
def test_sizeof_fmt():

0 commit comments

Comments
 (0)