Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3b7e8a6
Remove dead and unused 3D code
larsoner Jul 2, 2026
233f71e
ENH: Speed up plotting by concatenating meshes
larsoner Jul 2, 2026
08121c0
FIX: Fixes
larsoner Jul 3, 2026
c96a7ee
FIX: Refs
larsoner Jul 3, 2026
d155e28
FIX: Move
larsoner Jul 3, 2026
1937671
FIX: Revert
larsoner Jul 3, 2026
7fe16c7
TST: Ping [circle full]
larsoner Jul 3, 2026
dde324f
FIX: Info
larsoner Jul 3, 2026
089a30b
TST: Meant to full [circle full]
larsoner Jul 3, 2026
b8c9d4e
WIP: Try [circle full] [skip azp] [skip actions]
larsoner Jul 3, 2026
ddb02d1
FIX: Better naming [circle full] [skip azp] [skip actions]
larsoner Jul 3, 2026
8f0c675
FIX:? [circle full]
larsoner Jul 3, 2026
7f2996d
FIX: Fixes [circle full]
larsoner Jul 3, 2026
4a7e8bf
FIX: instantiated mesh
larsoner Jul 3, 2026
6b2391c
FIX: Switch to refleak [circle full]
larsoner Jul 3, 2026
a52a8d8
FIX: Remove [circle full]
larsoner Jul 3, 2026
36fd7f5
FIX: Simplify [circle full]
larsoner Jul 3, 2026
c2930ff
FIX: Cleaner [circle full]
larsoner Jul 3, 2026
b16ad2d
FIX: Import [circle full]
larsoner Jul 3, 2026
8b8be2f
FIX: Missing [circle full]
larsoner Jul 4, 2026
52ef603
FIX: More
larsoner Jul 4, 2026
dc9f63b
FIX: Updpate
larsoner Jul 4, 2026
e870a27
FIX: Cleaner
larsoner Jul 4, 2026
2b44ae2
TST: More [circle full]
larsoner Jul 4, 2026
4c08695
FIX: use it
larsoner Jul 4, 2026
8f7a476
TST [circle full]
larsoner Jul 4, 2026
cf7eed7
FIX: More [circle full]
larsoner Jul 4, 2026
efdd6d6
FIX: Fine [circle full]
larsoner Jul 4, 2026
1e47d5f
Remove unused import in background_filtering.py
larsoner Jul 4, 2026
8962472
Merge branch 'main' into cleanup
larsoner Jul 4, 2026
0e38f39
Update minimum-phase filter implementation
larsoner Jul 4, 2026
d598d42
FIX: Simplification
larsoner Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/14016.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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`_.
34 changes: 23 additions & 11 deletions doc/sphinxext/mne_doc_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,19 @@
import functools
import gc
import os
import sys
import time
import warnings
from pathlib import Path

import numpy as np
import pyvista
import sphinx.util.logging
from refleak.testing import assert_no_instances
from sphinx.errors import ExtensionError

import mne
from mne.utils import (
_assert_no_instances,
_get_extra_data_path,
sizeof_fmt,
)
from mne.utils import Bunch, _get_extra_data_path, sizeof_fmt
from mne.viz import Brain

sphinx_logger = sphinx.util.logging.getLogger("mne")
Expand Down Expand Up @@ -177,6 +175,18 @@ def reset_modules(gallery_conf, fname, when):
neo.io.stimfitio.STFIO_ERR = None
except Exception:
pass
# IPython.core.completer does `import __main__` at import time, permanently
# capturing whatever sys.modules['__main__'] was at that moment. Since SG
# temporarily swaps sys.modules['__main__'] to each example's throwaway
# namespace while it runs, if that import happens to fire during one of
# those windows, it pins that example's globals (and anything reachable
# from them) alive for the rest of the process.
try:
import IPython.core.completer

IPython.core.completer.__main__ = sys.modules["__main__"]
except Exception:
pass
gc.collect()

# Agg does not call close_event so let's clean up on our own :(
Expand All @@ -192,19 +202,21 @@ def reset_modules(gallery_conf, fname, when):
# to just test MNEQtBrowser
skips = os.getenv("MNE_SKIP_INSTANCE_ASSERTIONS", "").lower()
prefix = ""
request = Bunch() # just give it something to say "we have done GC already"
request.node = Bunch()
if skips not in ("true", "1", "all"):
prefix = "Clean "
skips = skips.split(",")
if "brain" not in skips:
_assert_no_instances(Brain, when) # calls gc.collect()
assert_no_instances(Brain, when=when, request=request)
if Plotter is not None and "plotter" not in skips:
_assert_no_instances(Plotter, when)
assert_no_instances(Plotter, when=when, request=request)
if BackgroundPlotter is not None and "backgroundplotter" not in skips:
_assert_no_instances(BackgroundPlotter, when)
assert_no_instances(BackgroundPlotter, when=when, request=request)
if vtkPolyData is not None and "vtkpolydata" not in skips:
_assert_no_instances(vtkPolyData, when)
assert_no_instances(vtkPolyData, when=when, request=request)
if "_renderer" not in skips:
_assert_no_instances(_Renderer, when)
assert_no_instances(_Renderer, when=when, request=request)
if MNEQtBrowser is not None and "mneqtbrowser" not in skips:
# Ensure any manual fig.close() events get properly handled
from mne_qt_browser._pg_figure import QApplication
Expand All @@ -213,7 +225,7 @@ def reset_modules(gallery_conf, fname, when):
if inst is not None:
for _ in range(2):
inst.processEvents()
_assert_no_instances(MNEQtBrowser, when)
assert_no_instances(MNEQtBrowser, when=when, request=request)
# This will overwrite some Sphinx printing but it's useful
# for memory timestamps
if os.getenv("SG_STAMP_STARTS", "").lower() == "true":
Expand Down
8 changes: 4 additions & 4 deletions doc/sphinxext/related_software.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
from sphinx.errors import ExtensionError
from sphinx.util.display import status_iterator

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

# If it's available on PyPI, add it to this set:
# 2. If it's available on PyPI, add it to this set:
PYPI_PACKAGES = {
"cross-domain-saliency-maps",
"meggie",
Expand All @@ -41,7 +41,7 @@
"zuna",
}

# If it's not available on PyPI, add it to this dict:
# 3. If it's not available on PyPI, add it to this dict:
MANUAL_PACKAGES = {
# TODO: These packages are not pip-installable as of 2025/11/19, so we have to
# manually populate them -- should open issues on their package repos.
Expand Down
19 changes: 13 additions & 6 deletions mne/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import pytest
from packaging.version import Version
from pytest import StashKey, register_assert_rewrite
from refleak.testing import assert_no_instances, gc_collect_once

# Any `assert` statements in our testing functions should be verbose versions
register_assert_rewrite("mne.utils._testing")
Expand All @@ -38,7 +39,6 @@
from mne.stats import cluster_level
from mne.utils import (
Bunch,
_assert_no_instances,
_check_qt_version,
_chmod_rw_R,
_pl,
Expand Down Expand Up @@ -628,10 +628,10 @@ def triaxial_evoked(triaxial_raw):


@pytest.fixture
def garbage_collect():
def garbage_collect(request):
"""Garbage collect on exit."""
yield
gc.collect()
gc_collect_once(request)


@pytest.fixture
Expand Down Expand Up @@ -687,7 +687,9 @@ def pg_backend(request, garbage_collect):
mne_qt_browser._browser_instances.clear()
if not _test_passed(request):
return
_assert_no_instances(MNEQtBrowser, f"Closure of {request.node.name}")
assert_no_instances(
MNEQtBrowser, f"Closure of {request.node.name}", request=request
)


@pytest.fixture(
Expand Down Expand Up @@ -1081,8 +1083,13 @@ def brain_gc(request):
close_func()
if not _test_passed(request):
return
_assert_no_instances(Brain, "after")
# Check VTK
gc_collect_once(request)
# Brain._instances is a WeakSet populated only when MNE_3D_BACKEND_TESTING
# is set (see Brain.__init__), so use it instead of a slow gc.get_objects()
# scan of the whole process to check for lingering Brain instances.
assert_no_instances(Brain, "after", request=request, objs=list(Brain._instances))
# Check VTK -- these aren't individually tracked, so we still need a full
# heap scan here.
objs = gc.get_objects()
bad = list()
for o in objs:
Expand Down
5 changes: 4 additions & 1 deletion mne/gui/tests/test_coreg.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ def test_coreg_gui_pyvista_basic(tmp_path, monkeypatch, renderer_interactive_pyv
coreg._redraw(verbose="debug")
assert "Drawing meg sensors" in log.getvalue()
assert coreg._actors["helmet"] is not None
assert len(coreg._actors["sensors"]) == 306
# coil surfaces sharing a shape are GPU-instanced into a single
# mesh/actor; this Neuromag sample dataset has 2 distinct MEG coil
# shapes (magnetometer + planar gradiometer)
assert len(coreg._actors["sensors"]) == 2
assert coreg._orient_glyphs
assert coreg._scale_by_distance
assert coreg._mark_inside
Expand Down
14 changes: 14 additions & 0 deletions mne/surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,20 @@ def _tessellate_sphere(mylevel):
return rr, tris


def _decimate_surface_ico_oct(subject, subjects_dir, hemi, surf, spacing):
from .source_space._source_space import _check_spacing

stype, _, ico_surf, _ = _check_spacing(spacing, verbose=False)
subjects_dir = Path(subjects_dir)
surf_fname = subjects_dir / subject / "surf" / f"{hemi}.{surf}"
dec = _create_surf_spacing(surf_fname, hemi, subject, stype, ico_surf, subjects_dir)
vertno, use_tris = dec["vertno"], dec["use_tris"]
lut = np.zeros(dec["np"], int)
lut[vertno] = np.arange(len(vertno))
tris = lut[use_tris]
return vertno, tris


def _create_surf_spacing(surf, hemi, subject, stype, ico_surf, subjects_dir):
"""Load a surf and use the subdivided icosahedron to get points."""
# Based on load_source_space_surf_spacing() in load_source_space.c
Expand Down
1 change: 1 addition & 0 deletions mne/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,7 @@ def sys_info(
"pytest-qt",
"pytest-rerunfailures",
"pytest-timeout",
"refleak",
"codespell",
"ipython",
"mypy",
Expand Down
78 changes: 5 additions & 73 deletions mne/utils/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# Copyright the MNE-Python contributors.

import fnmatch
import gc
import hashlib
import inspect
import os
Expand Down Expand Up @@ -353,80 +352,13 @@ def _file_like(obj):
return all(callable(getattr(obj, name, None)) for name in ("read", "seek"))


def _fullname(obj, *, referent=None):
klass = obj.__class__
module = klass.__module__
name = klass.__qualname__
if module != "builtins":
name = f"{module}.{name}"
if referent is not None:
if isinstance(obj, list | tuple):
for ii, item in enumerate(obj):
if item is referent:
name += f"[{ii}]"
break
elif isinstance(obj, dict):
for key, value in obj.items():
if key is referent:
name += "-key"
break
if value is referent:
name += f"[{key!r}]"
break
return name


# Low-effort backward compat wrapper in case other MNE libraries use this function
def _assert_no_instances(cls, when=""):
from refleak.testing import assert_no_instances

__tracebackhide__ = True
n = 0
ref = list()
gc.collect()
objs = gc.get_objects()
for obj in objs: # e.g., vtkPolyData, Brain, Plotter, etc.
try:
check = isinstance(obj, cls)
except Exception: # such as a weakref
check = False
if check:
if cls.__name__ == "Brain":
ref.append(f"Brain._cleaned = {getattr(obj, '_cleaned', None)}")
rr = gc.get_referrers(obj)
count = 0
for r in rr: # e.g., list, dict, etc. that holds the reference to obj
if (
r is not objs
and r is not globals()
and r is not locals()
and not inspect.isframe(r)
):
name = _fullname(r, referent=obj)
if isinstance(r, list | dict | tuple):
rep = f"len={len(r)}"
r_ = gc.get_referrers(r)
types = list()
for x in r_:
types.append(_fullname(x, referent=r))
types = " / ".join(sorted(types))
rep += f" | {len(r_)} referrers: {types}"
del r_
else:
rep = "repr="
rep += repr(r)[:100].replace("\n", " ")
# If it's a __closure__, get more information
if rep.startswith("<cell at "):
try:
rep += f" ({repr(r.cell_contents)[:100]})"
except Exception:
pass
ref.append(f"{name} with {rep}")
count += 1
del r
del rr
n += count > 0
del obj
del objs
gc.collect()
assert n == 0, f"\n{n} {cls.__name__} @ {when}:\n" + "\n".join(ref)

assert_no_instances(cls, when=when)


def _resource_path(submodule, filename):
Expand Down
20 changes: 19 additions & 1 deletion mne/utils/tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,25 @@
import pytest

import mne
from mne.utils import _clean_names, catch_logging, run_subprocess, sizeof_fmt
from mne.utils import (
_assert_no_instances,
_clean_names,
catch_logging,
run_subprocess,
sizeof_fmt,
)


def test_assert_no_instances():
"""Test that our wrapper works."""

class _Foo:
pass

_holder = {"key": _Foo()}

with pytest.raises(AssertionError, match="after closing"):
_assert_no_instances(_Foo, "after closing")


def test_sizeof_fmt():
Expand Down
Loading
Loading