From 3b7e8a64ae372cb54769118e73a8659cae5a3d25 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 2 Jul 2026 17:10:07 -0400 Subject: [PATCH 01/31] Remove dead and unused 3D code --- mne/viz/_3d.py | 2 - mne/viz/backends/_abstract.py | 16 +-- mne/viz/backends/_pyvista.py | 187 ++++++++++++---------------------- 3 files changed, 68 insertions(+), 137 deletions(-) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 2c5e081b8f0..022bea32b07 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1227,7 +1227,6 @@ def _plot_axes(renderer, info, to_cf_t, head_mri_t): scale=2e-2, color=ax[1], scale_mode="scalar", - resolution=20, scalars=[0.33, 0.66, 1.0], ) actors.append(actor) @@ -1424,7 +1423,6 @@ def _plot_glyphs( mode=mode, glyph_height=defaults["eegp_height"], glyph_center=(0.0, -defaults["eegp_height"], 0), - resolution=16, glyph_resolution=16, glyph_radius=None, opacity=opacity, diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 7c9d97167aa..72c665b62d4 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -97,7 +97,7 @@ def set_interaction(self, interaction): @classmethod @abstractmethod - def legend(self, labels, border=False, size=0.1, face="triangle", loc="upper left"): + def legend(self, labels, size=0.1, face="triangle", loc="upper left"): """Add a legend to the scene. Parameters @@ -106,9 +106,6 @@ def legend(self, labels, border=False, size=0.1, face="triangle", loc="upper lef Each entry must contain two strings, (label, color), where ``label`` is the name of the item to add, and ``color`` is the color of the label to add. - border : bool - Controls if there will be a border around the legend. - The default is False. size : float The size of the entire figure window. loc : str @@ -144,7 +141,6 @@ def mesh( representation="surface", line_width=1.0, normals=None, - polygon_offset=None, name=None, **kwargs, ): @@ -190,8 +186,6 @@ def mesh( The width of the lines when representation='wireframe'. normals : array, shape (n_vertices, 3) The array containing the normal of each vertex. - polygon_offset : float - If not None, the factor used to resolve coincident topology. name : str | None The name of the mesh. kwargs : args @@ -266,7 +260,6 @@ def surface( normalized_colormap=False, scalars=None, backface_culling=False, - polygon_offset=None, *, name=None, ): @@ -294,8 +287,6 @@ def surface( The scalar valued associated to the vertices. backface_culling : bool If True, enable backface culling on the surface. - polygon_offset : float - If not None, the factor used to resolve coincident topology. name : str | None Name of the surface. """ @@ -407,7 +398,6 @@ def quiver3d( color, scale, mode, - resolution=8, glyph_height=None, glyph_center=None, glyph_resolution=None, @@ -447,10 +437,6 @@ def quiver3d( The given value specifies the maximum glyph size in drawing units. mode : 'arrow', 'cone' or 'cylinder' The type of the quiver. - resolution : int - The resolution of the glyph created. Depending on the type of - glyph, it represents the number of divisions in its geometric - representation. glyph_height : float The height of the glyph used with the quiver. glyph_center : tuple diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index ca61d601cf7..8f35083f2b0 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -17,45 +17,20 @@ import numpy as np import pyvista -from pyvista import Line, Plotter, PolyData, UnstructuredGrid, close_all +from pyvista import Line, Plotter, PolyData, close_all +from pyvista.plotting.plotter import _ALL_PLOTTERS from pyvistaqt import BackgroundPlotter - -from ...fixes import _compare_version -from ...surface import _vtk_smooth -from ...transforms import _cart_to_sph, _sph_to_cart, apply_trans -from ...utils import ( - _check_option, - _require_version, - _validate_type, - warn, -) -from ._abstract import Figure3D, _AbstractRenderer -from ._utils import ( - ALLOWED_QUIVER_MODES, - _alpha_blend_background, - _get_colormap_from_array, - _init_mne_qtapp, -) - -try: - from pyvista.plotting.plotter import _ALL_PLOTTERS -except Exception: # PV < 0.40 - from pyvista.plotting.plotting import _ALL_PLOTTERS - from vtkmodules.util.numpy_support import numpy_to_vtk from vtkmodules.vtkCommonCore import VTK_UNSIGNED_CHAR, vtkCommand, vtkLookupTable -from vtkmodules.vtkCommonDataModel import VTK_VERTEX, vtkPiecewiseFunction +from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction from vtkmodules.vtkCommonTransforms import vtkTransform from vtkmodules.vtkFiltersCore import vtkGlyph3D from vtkmodules.vtkFiltersGeneral import vtkMarchingContourFilter from vtkmodules.vtkFiltersHybrid import vtkPolyDataSilhouette from vtkmodules.vtkFiltersSources import ( vtkArrowSource, - vtkConeSource, vtkCylinderSource, vtkGlyphSource2D, - vtkPlatonicSolidSource, - vtkSphereSource, ) from vtkmodules.vtkImagingCore import vtkImageReslice from vtkmodules.vtkRenderingCore import ( @@ -70,6 +45,23 @@ ) from vtkmodules.vtkRenderingVolumeOpenGL2 import vtkSmartVolumeMapper +from ...fixes import _compare_version +from ...surface import _vtk_smooth +from ...transforms import _cart_to_sph, _sph_to_cart, apply_trans +from ...utils import ( + _check_option, + _require_version, + _validate_type, + warn, +) +from ._abstract import Figure3D, _AbstractRenderer +from ._utils import ( + ALLOWED_QUIVER_MODES, + _alpha_blend_background, + _get_colormap_from_array, + _init_mne_qtapp, +) + try: from vtkmodules.vtkFiltersGeneral import vtkTransformFilter except ImportError: # TODO VERSION VTK 9.7+ @@ -314,22 +306,18 @@ def scene(self): def update_lighting(self): # Inspired from Mayavi's version of Raymond Maple 3-lights illumination + # below and centered, left and above, right and above + az_el_in = ((0, -45, 0.7), (-60, 30, 0.7), (60, 30, 0.7)) for renderer in self._all_renderers: - lights = list(renderer.GetLights()) - headlight = lights.pop(0) - headlight.SetSwitch(False) - # below and centered, left and above, right and above - az_el_in = ((0, -45, 0.7), (-60, 30, 0.7), (60, 30, 0.7)) - for li, light in enumerate(lights): - if li < len(az_el_in): - light.SetSwitch(True) - light.SetPosition(_to_pos(*az_el_in[li][:2])) - light.SetIntensity(az_el_in[li][2]) - else: - light.SetSwitch(False) - light.SetPosition(_to_pos(0.0, 0.0)) - light.SetIntensity(0.0) - light.SetColor(1.0, 1.0, 1.0) + renderer.remove_all_lights() + for azimuth, elevation, intensity in az_el_in: + light = pyvista.Light( + position=_to_pos(azimuth, elevation), + color="white", + light_type="camera light", + intensity=intensity, + ) + renderer.add_light(light) def set_interaction(self, interaction): if not hasattr(self.plotter, "iren") or self.plotter.iren is None: @@ -346,7 +334,7 @@ def set_interaction(self, interaction): kwargs["mouse_wheel_zooms"] = True getattr(self.plotter, f"enable_{interaction}_style")(**kwargs) - def legend(self, labels, border=False, size=0.1, face="triangle", loc="upper left"): + def legend(self, labels, size=0.1, face="triangle", loc="upper left"): return self.plotter.add_legend(labels, size=(size, size), face=face, loc=loc) def polydata( @@ -363,7 +351,6 @@ def polydata( interpolate_before_map=True, representation="surface", line_width=1.0, - polygon_offset=None, *, name=None, **kwargs, @@ -413,13 +400,6 @@ def polydata( **kwargs, ) - if polygon_offset is not None: - mapper = actor.GetMapper() - mapper.SetResolveCoincidentTopologyToPolygonOffset() - mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters( - polygon_offset, polygon_offset - ) - return actor, mesh def mesh( @@ -440,7 +420,6 @@ def mesh( representation="surface", line_width=1.0, normals=None, - polygon_offset=None, name=None, **kwargs, ): @@ -460,7 +439,6 @@ def mesh( interpolate_before_map=interpolate_before_map, representation=representation, line_width=line_width, - polygon_offset=polygon_offset, name=name, **kwargs, ) @@ -516,7 +494,6 @@ def surface( normalized_colormap=False, scalars=None, backface_culling=False, - polygon_offset=None, *, name=None, ): @@ -538,7 +515,6 @@ def surface( colormap=colormap, vmin=vmin, vmax=vmax, - polygon_offset=polygon_offset, name=name, ) @@ -552,21 +528,17 @@ def sphere( backface_culling=False, radius=None, ): - from vtkmodules.vtkFiltersSources import vtkSphereSource - factor = 1.0 if radius is not None else scale center = np.array(center, dtype=float) if len(center) == 0: return None, None _check_option("center.ndim", center.ndim, (1, 2)) _check_option("center.shape[-1]", center.shape[-1], (3,)) - sphere = vtkSphereSource() - sphere.SetThetaResolution(resolution) - sphere.SetPhiResolution(resolution) - if radius is not None: - sphere.SetRadius(radius) - sphere.Update() - geom = sphere.GetOutput() + geom = pyvista.Sphere( + radius=0.5 if radius is None else radius, + theta_resolution=resolution, + phi_resolution=resolution, + ) mesh = PolyData(center) glyph = mesh.glyph(orient=False, scale=False, factor=factor, geom=geom) actor = _add_mesh( @@ -628,7 +600,6 @@ def quiver3d( color, scale, mode, - resolution=8, *, glyph_height=None, glyph_center=None, @@ -648,12 +619,9 @@ def quiver3d( _check_option("scale_mode", scale_mode, list(scale_map)) factor = scale vectors = np.c_[u, v, w] - points = np.vstack(np.c_[x, y, z]) + points = np.vstack(np.c_[x, y, z]).astype(float) n_points = len(points) - cell_type = np.full(n_points, VTK_VERTEX) - cells = np.c_[np.full(n_points, 1), range(n_points)] - args = (cells, cell_type, points) - grid = UnstructuredGrid(*args) + grid = PolyData(points) if scalars is None: scalars = np.ones((n_points,)) mesh_scalars = None @@ -667,45 +635,39 @@ def quiver3d( alg = _glyph(grid, orient="vec", scalars="scalars", factor=factor) mesh = pyvista.wrap(alg.GetOutput()) else: - tr = None if mode == "cone": - glyph = vtkConeSource() - glyph.SetCenter(0.5, 0, 0) - if glyph_radius is not None: - glyph.SetRadius(glyph_radius) + geom = pyvista.Cone(center=(0.5, 0, 0), radius=glyph_radius) elif mode == "cylinder": - glyph = vtkCylinderSource() + # vtkCylinderSource's axis is along y, so rotate 90 degrees + # about z to match the arrow/cone convention of pointing + # along x + source = vtkCylinderSource() if glyph_radius is not None: - glyph.SetRadius(glyph_radius) - elif mode == "oct": - glyph = vtkPlatonicSolidSource() - glyph.SetSolidTypeToOctahedron() - else: - assert mode == "sphere", mode # guaranteed above - glyph = vtkSphereSource() - if mode == "cylinder": + source.SetRadius(glyph_radius) if glyph_height is not None: - glyph.SetHeight(glyph_height) + source.SetHeight(glyph_height) if glyph_center is not None: - glyph.SetCenter(glyph_center) + source.SetCenter(glyph_center) if glyph_resolution is not None: - glyph.SetResolution(glyph_resolution) + source.SetResolution(glyph_resolution) + source.Update() tr = vtkTransform() tr.RotateWXYZ(90, 0, 0, 1) + trp = vtkTransformFilter() + trp.SetInputData(source.GetOutput()) + trp.SetTransform(tr) + trp.Update() + geom = trp.GetOutput() elif mode == "oct": + geom = pyvista.PlatonicSolid(kind="octahedron") if solid_transform is not None: assert solid_transform.shape == (4, 4) - tr = vtkTransform() - tr.SetMatrix(solid_transform.astype(np.float64).ravel()) - if tr is not None: - # fix orientation - glyph.Update() - trp = vtkTransformFilter() - trp.SetInputData(glyph.GetOutput()) - trp.SetTransform(tr) - glyph = trp - glyph.Update() - geom = glyph.GetOutput() + geom = geom.transform( + solid_transform.astype(np.float64), inplace=True + ) + else: + assert mode == "sphere", mode # guaranteed above + geom = pyvista.Sphere(theta_resolution=8, phi_resolution=8) mesh = grid.glyph( orient="vec", scale=scale_map[scale_mode], @@ -761,7 +723,7 @@ def text2d( return actor def text3d(self, x, y, z, text, scale, color="white"): - kwargs = dict( + actor = self.plotter.add_point_labels( points=np.array([x, y, z]).astype(float), labels=[text], point_size=scale, @@ -769,10 +731,8 @@ def text3d(self, x, y, z, text, scale, color="white"): font_family=self.font_family, name=text, shape_opacity=0, + always_visible=True, ) - if "always_visible" in signature(self.plotter.add_point_labels).parameters: - kwargs["always_visible"] = True - actor = self.plotter.add_point_labels(**kwargs) _hide_testing_actor(actor) return actor @@ -946,15 +906,9 @@ def _set_volume_range(self, volume, ctable, alpha, scalar_bar, rng): scalar_bar.SetLookupTable(lut) def _sphere(self, center, color, radius): - from vtkmodules.vtkFiltersSources import vtkSphereSource - - sphere = vtkSphereSource() - sphere.SetThetaResolution(8) - sphere.SetPhiResolution(8) - sphere.SetRadius(radius) - sphere.SetCenter(center) - sphere.Update() - mesh = pyvista.wrap(sphere.GetOutput()) + mesh = pyvista.Sphere( + radius=radius, center=center, theta_resolution=8, phi_resolution=8 + ) actor = _add_mesh(self.plotter, mesh=mesh, color=color) return actor, mesh @@ -1339,14 +1293,7 @@ def _glyph( @contextmanager def _disabled_depth_peeling(): - try: - from pyvista import global_theme - except Exception: # workaround for older PyVista - from pyvista import rcParams - - depth_peeling = rcParams["depth_peeling"] - else: - depth_peeling = global_theme.depth_peeling + depth_peeling = pyvista.global_theme.depth_peeling depth_peeling_enabled = depth_peeling["enabled"] depth_peeling["enabled"] = False try: From 233f71ee116ee2adc414c82780f683034b8ed582 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 2 Jul 2026 19:16:17 -0400 Subject: [PATCH 02/31] ENH: Speed up plotting by concatenating meshes --- doc/changes/dev/14016.newfeature.rst | 1 + mne/gui/tests/test_coreg.py | 3 +- mne/source_space/_source_space.py | 13 +++++++ mne/viz/_3d.py | 21 ++++++++++- mne/viz/_brain/_brain.py | 37 ++++++++++++++++--- mne/viz/_brain/tests/test_brain.py | 4 +- mne/viz/tests/test_3d.py | 10 ++--- tutorials/inverse/50_beamformer_lcmv.py | 9 ++--- .../preprocessing/70_fnirs_processing.py | 4 +- 9 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 doc/changes/dev/14016.newfeature.rst diff --git a/doc/changes/dev/14016.newfeature.rst b/doc/changes/dev/14016.newfeature.rst new file mode 100644 index 00000000000..51bbbeb8fc0 --- /dev/null +++ b/doc/changes/dev/14016.newfeature.rst @@ -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`_. diff --git a/mne/gui/tests/test_coreg.py b/mne/gui/tests/test_coreg.py index feb28ed770f..8562cb45b23 100644 --- a/mne/gui/tests/test_coreg.py +++ b/mne/gui/tests/test_coreg.py @@ -263,7 +263,8 @@ 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 color are merged into a single mesh/actor + assert len(coreg._actors["sensors"]) == 1 assert coreg._orient_glyphs assert coreg._scale_by_distance assert coreg._mark_inside diff --git a/mne/source_space/_source_space.py b/mne/source_space/_source_space.py index 3bf6ec92bbf..113ddc97ae4 100644 --- a/mne/source_space/_source_space.py +++ b/mne/source_space/_source_space.py @@ -9,6 +9,7 @@ import os.path as op from copy import deepcopy from functools import partial +from pathlib import Path import numpy as np from scipy.sparse import csr_array, triu @@ -1490,6 +1491,18 @@ def _check_spacing(spacing, verbose=None): return stype, sval, ico_surf, src_type_str +def _decimate_surface_ico_oct(subject, subjects_dir, hemi, surf, 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 + + @verbose def setup_source_space( subject, diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 022bea32b07..26ef2b50493 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1648,9 +1648,16 @@ def _plot_sensors_3d( if isinstance(sens_loc[0], dict): # meg coil if len(colors) == 1: colors = [colors[0]] * len(sens_loc) + # Merge same-color coils into one mesh/actor instead of one per + # coil (can number in the hundreds), since per-call overhead + # dominates. Could also use a GPU-instanced vtkGlyph3DMapper + # (quaternion mode), but that's a larger refactor for little gain. + groups = defaultdict(list) for surface, color in zip(sens_loc, colors): + groups[tuple(color)].append(surface) + for color, surfaces in groups.items(): actor, _ = renderer.surface( - surface=surface, + surface=_merge_surfaces(surfaces), color=color[:3], opacity=this_alpha * color[3], backface_culling=False, # visible from all sides @@ -1761,6 +1768,18 @@ def _plot_sensors_3d( return actors +def _merge_surfaces(surfaces): + """Merge a list of surface dicts (rr, tris) into a single surface dict.""" + rrs = list() + tris = list() + offset = 0 + for surface in surfaces: + rrs.append(surface["rr"]) + tris.append(surface["tris"] + offset) + offset += len(surface["rr"]) + return dict(rr=np.concatenate(rrs), tris=np.concatenate(tris)) + + def _make_tris_fan(n_vert): """Make tris given a number of vertices of a circle-like obj.""" tris = np.zeros((n_vert - 2, 3), int) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index b66bdf5c436..b9f8d71ce4c 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -29,6 +29,7 @@ ) from ...defaults import DEFAULTS, _handle_default from ...fixes import _reshape_view +from ...source_space._source_space import _decimate_surface_ico_oct from ...surface import _marching_cubes, _mesh_borders, mesh_edges from ...transforms import ( Transform, @@ -164,9 +165,15 @@ class Brain: %(view_layout)s silhouette : dict | bool As a dict, it contains the ``color``, ``linewidth``, ``alpha`` opacity - and ``decimate`` (level of decimation between 0 and 1 or None) of the - brain's silhouette to display. If True, the default values are used - and if False, no silhouette will be displayed. Defaults to False. + and ``decimate`` of the brain's silhouette to display. ``decimate`` can be + a level of decimation between 0 and 1, None for no decimation, or a spacing + string (e.g. ``"ico4"``, ``"oct6"``) to instead pick vertices the same way + :func:`mne.setup_source_space` does, which is faster and better preserves + the shape than plain decimation. If True, the default values are used and + if False, no silhouette will be displayed. Defaults to False. + + .. versionchanged:: 1.13 + The default ``decimate`` value changed from ``0.9`` to ``"ico5"``. %(theme_3d)s show : bool Display the window as soon as it is ready. Defaults to True. @@ -366,7 +373,7 @@ def __init__( "color": self._bg_color, "line_width": 2, "alpha": alpha, - "decimate": 0.9, + "decimate": "ico5", } _validate_type(silhouette, (dict, bool), "silhouette") if isinstance(silhouette, dict): @@ -450,12 +457,30 @@ def __init__( self._renderer.plotter.add_actor(actor, render=False) if self.silhouette: mesh = self.layered_meshes[h] + decimate = self._silhouette["decimate"] + if isinstance(decimate, str): + import pyvista as pv + + vertno, tris = _decimate_surface_ico_oct( + self._subject, + self._subjects_dir, + h, + self.geo[h].surf, + decimate, + ) + sil_mesh = pv.PolyData( + self.geo[h].coords[vertno], + np.c_[np.full(len(tris), 3), tris], + ) + decimate = None # already decimated + else: + sil_mesh = mesh._polydata self._renderer._silhouette( - mesh=mesh._polydata, + mesh=sil_mesh, color=self._silhouette["color"], line_width=self._silhouette["line_width"], alpha=self._silhouette["alpha"], - decimate=self._silhouette["decimate"], + decimate=decimate, ) self._set_camera(**views_dicts[h][v]) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index d50495b14ab..05ded04fbb6 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -638,7 +638,7 @@ def test_add_sensors_scales(renderer_interactive_pyvistaqt): title=title, cortex=cortex, units="m", - silhouette=dict(decimate=0.95), + silhouette=dict(decimate="oct6"), **kwargs, ) @@ -957,7 +957,7 @@ def test_brain_time_viewer(renderer_interactive_pyvistaqt, pixel_ratio, brain_gc brain = _create_testing_brain( hemi="both", show_traces=False, - brain_kwargs=dict(silhouette=dict(decimate=0.95)), + brain_kwargs=dict(silhouette=dict(decimate="ico5")), ) # test sub routines when show_traces=False brain._on_pick(None, None) diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 154313a1eeb..f30ccd803ce 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -485,13 +485,9 @@ def test_plot_alignment_meg(renderer, system): sensor_colors=sensor_colors, ) assert isinstance(fig, Figure3D) - # count the number of objects: should be n_meg_ch + 1 (helmet) + 1 (head) - use_info = pick_info( - this_info, - pick_types(this_info, meg=True, eeg=False, ref_meg="ref" in meg, exclude=()), - ) - n_actors = use_info["nchan"] + 2 - _assert_n_actors(fig, renderer, n_actors) + # 1 actor per distinct coil color (merged for speed) + helmet + head + n_colors = 2 if system == "KIT" else 1 # KIT also has a separate ref_meg color + _assert_n_actors(fig, renderer, n_colors + 2) @testing.requires_testing_data diff --git a/tutorials/inverse/50_beamformer_lcmv.py b/tutorials/inverse/50_beamformer_lcmv.py index bd7228fa840..0add1434fd2 100644 --- a/tutorials/inverse/50_beamformer_lcmv.py +++ b/tutorials/inverse/50_beamformer_lcmv.py @@ -23,11 +23,10 @@ # %% # Introduction to beamformers # --------------------------- -# A beamformer is a spatial filter that reconstructs source activity by -# scanning through a grid of pre-defined source points and estimating activity -# at each of those source points independently. A set of weights is -# constructed for each defined source location which defines the contribution -# of each sensor to this source. +# A beamformer is a spatial filter that reconstructs source activity by scanning through +# a grid of pre-defined source points and estimating activity at each of those source +# points independently. A set of weights is constructed for each defined source location +# which defines the contribution of each sensor to this source. # # Beamformers are often used for their focal reconstructions and their ability # to reconstruct deeper sources. They can also suppress external noise sources. diff --git a/tutorials/preprocessing/70_fnirs_processing.py b/tutorials/preprocessing/70_fnirs_processing.py index afafa9c7bdd..efae1fe04ed 100644 --- a/tutorials/preprocessing/70_fnirs_processing.py +++ b/tutorials/preprocessing/70_fnirs_processing.py @@ -18,8 +18,6 @@ # %% -from itertools import compress - import matplotlib.pyplot as plt import numpy as np @@ -118,7 +116,7 @@ # In this example we will mark all channels with a SCI less than 0.5 as bad # (this dataset is quite clean, so no channels are marked as bad). -raw_od.info["bads"] = list(compress(raw_od.ch_names, sci < 0.5)) +raw_od.info["bads"] = [raw_od.ch_names[idx] for idx in np.where(sci < 0.5)[0]] # %% # At this stage it is appropriate to inspect your data From 08121c0ab8905bf531f4bbccf7456ec9f9b6dc1b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 2 Jul 2026 23:26:33 -0400 Subject: [PATCH 03/31] FIX: Fixes --- doc/sphinxext/related_software.py | 9 ++++----- mne/source_space/_source_space.py | 13 ------------- mne/surface.py | 14 ++++++++++++++ mne/viz/_brain/_brain.py | 10 +++++++--- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/doc/sphinxext/related_software.py b/doc/sphinxext/related_software.py index 79a56fac6a1..bc3d2a34cc3 100644 --- a/doc/sphinxext/related_software.py +++ b/doc/sphinxext/related_software.py @@ -29,20 +29,19 @@ 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", "niseq", "sesameeg", - "mne-kit-gui", # moved to its own env in the installers "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. diff --git a/mne/source_space/_source_space.py b/mne/source_space/_source_space.py index 113ddc97ae4..3bf6ec92bbf 100644 --- a/mne/source_space/_source_space.py +++ b/mne/source_space/_source_space.py @@ -9,7 +9,6 @@ import os.path as op from copy import deepcopy from functools import partial -from pathlib import Path import numpy as np from scipy.sparse import csr_array, triu @@ -1491,18 +1490,6 @@ def _check_spacing(spacing, verbose=None): return stype, sval, ico_surf, src_type_str -def _decimate_surface_ico_oct(subject, subjects_dir, hemi, surf, 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 - - @verbose def setup_source_space( subject, diff --git a/mne/surface.py b/mne/surface.py index c3075bf7d9d..9153e080202 100644 --- a/mne/surface.py +++ b/mne/surface.py @@ -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 diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index b9f8d71ce4c..077d7249975 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -29,8 +29,12 @@ ) from ...defaults import DEFAULTS, _handle_default from ...fixes import _reshape_view -from ...source_space._source_space import _decimate_surface_ico_oct -from ...surface import _marching_cubes, _mesh_borders, mesh_edges +from ...surface import ( + _decimate_surface_ico_oct, + _marching_cubes, + _mesh_borders, + mesh_edges, +) from ...transforms import ( Transform, _frame_to_str, @@ -169,7 +173,7 @@ class Brain: a level of decimation between 0 and 1, None for no decimation, or a spacing string (e.g. ``"ico4"``, ``"oct6"``) to instead pick vertices the same way :func:`mne.setup_source_space` does, which is faster and better preserves - the shape than plain decimation. If True, the default values are used and + the shape than plain decimation. If True, ``"ico5"`` is used and if False, no silhouette will be displayed. Defaults to False. .. versionchanged:: 1.13 From c96a7ee79ff8589f9018778b387403d9d864458d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 00:45:28 -0400 Subject: [PATCH 04/31] FIX: Refs --- mne/conftest.py | 18 +++++++++++++----- mne/utils/__init__.pyi | 2 ++ mne/utils/misc.py | 28 ++++++++++++++++++++++++---- mne/viz/_brain/_brain.py | 20 ++++++++++++++++++-- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/mne/conftest.py b/mne/conftest.py index b6da95824e3..bf4590c8734 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -41,6 +41,7 @@ _assert_no_instances, _check_qt_version, _chmod_rw_R, + _gc_collect_once, _pl, _record_warnings, _TempDir, @@ -628,10 +629,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 @@ -687,7 +688,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( @@ -1081,8 +1084,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: diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index 73a0b6e2f6f..ad11a950859 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -72,6 +72,7 @@ __all__ = [ "_explain_exception", "_file_like", "_freq_mask", + "_gc_collect_once", "_gen_events", "_get_argvalues", "_get_blas_funcs", @@ -331,6 +332,7 @@ from .misc import ( _empty_hash, _explain_exception, _file_like, + _gc_collect_once, _get_argvalues, _pl, _resource_path, diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 38c14cbceb0..27e995beebf 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -376,12 +376,33 @@ def _fullname(obj, *, referent=None): return name -def _assert_no_instances(cls, when=""): +def _gc_collect_once(request=None): + """Call gc.collect(), deduplicated once per test item if given a request. + + ``gc.collect()`` cost scales with the number of tracked objects in the + whole process, so when several independent test fixtures each want a + fresh collect during the same test's teardown, doing it more than once + is a significant and unnecessary fraction of total test time. When + ``request`` (a pytest fixture request) is given, only the first call + for a given test item actually collects; later calls are no-ops. + """ + if request is None: + gc.collect() + return + node = request.node + if getattr(node, "_mne_gc_collected", False): + return + node._mne_gc_collected = True + gc.collect() + + +def _assert_no_instances(cls, when="", *, request=None, objs=None): __tracebackhide__ = True n = 0 ref = list() - gc.collect() - objs = gc.get_objects() + _gc_collect_once(request) + if objs is None: + objs = gc.get_objects() for obj in objs: # e.g., vtkPolyData, Brain, Plotter, etc. try: check = isinstance(obj, cls) @@ -425,7 +446,6 @@ def _assert_no_instances(cls, when=""): n += count > 0 del obj del objs - gc.collect() assert n == 0, f"\n{n} {cls.__name__} @ {when}:\n" + "\n".join(ref) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 077d7249975..ecafc7e7c1f 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -8,6 +8,7 @@ import time import traceback import warnings +import weakref from functools import partial from io import BytesIO @@ -286,6 +287,11 @@ class Brain: +-------------------------------------+--------------+---------------+ """ + # Tracks live instances when MNE_3D_BACKEND_TESTING is set (see + # __init__), so that tests can check for lingering instances without + # having to scan gc.get_objects() for the whole process. + _instances = weakref.WeakSet() + def __init__( self, subject, @@ -309,8 +315,13 @@ def __init__( theme=None, show=True, ): + from ..backends import renderer as _renderer_mod from ..backends.renderer import _get_renderer, backend + # This is only used in testing! + if _renderer_mod.MNE_3D_BACKEND_TESTING: + Brain._instances.add(self) + _validate_type(subject, str, "subject") self._surf = surf if hemi is None: @@ -414,7 +425,6 @@ def __init__( self._renderer._window_set_theme(theme) self.plotter = self._renderer.plotter self.widgets = dict() - self._setup_canonical_rotation() # plot hemis @@ -1455,7 +1465,13 @@ def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True): def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True): _ensure_int(vertex_id) assert isinstance(hemi, str), f"got {type(hemi)} for {hemi=}" - spheres = self._picked_points.pop((hemi, vertex_id)) + # When linked via _LinkViewer, removing a vertex on one brain cascades + # to all linked brains, so by the time a given brain's own loop (e.g. + # in clear_glyphs) reaches this (hemi, vertex_id) it may already be + # gone; just no-op in that case. + spheres = self._picked_points.pop((hemi, vertex_id), None) + if spheres is None: + return color, line = spheres[0]["color"], spheres[0]["line"] line.remove() self.mpl_canvas.update_plot() From d155e2845ced0d6da62839d7f903436f739d981f Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 00:53:40 -0400 Subject: [PATCH 05/31] FIX: Move --- mne/viz/_brain/_brain.py | 8 ++++---- mne/viz/_brain/_linkviewer.py | 19 ++++++++++++++++++- mne/viz/backends/_pyvista.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index ecafc7e7c1f..c1c0112f0f0 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -318,10 +318,6 @@ def __init__( from ..backends import renderer as _renderer_mod from ..backends.renderer import _get_renderer, backend - # This is only used in testing! - if _renderer_mod.MNE_3D_BACKEND_TESTING: - Brain._instances.add(self) - _validate_type(subject, str, "subject") self._surf = surf if hemi is None: @@ -368,6 +364,10 @@ def __init__( self.time_viewer = False self._hash = time.time_ns() + # This is only used in testing! Needs self._hash to already be set, + # since Brain.__hash__ (used when adding to the WeakSet) depends on it. + if _renderer_mod.MNE_3D_BACKEND_TESTING: + Brain._instances.add(self) self._hemi = hemi self._units = units self._alpha = float(alpha) diff --git a/mne/viz/_brain/_linkviewer.py b/mne/viz/_brain/_linkviewer.py index f642e2bff99..bcfcfa598d8 100644 --- a/mne/viz/_brain/_linkviewer.py +++ b/mne/viz/_brain/_linkviewer.py @@ -14,6 +14,7 @@ class _LinkViewer: def __init__(self, brains, time=True, camera=False, colorbar=True, picking=False): self.brains = brains self.leader = self.brains[0] # select a brain as leader + self._camera_observer = None # check time infos times = [brain._times for brain in brains] @@ -24,6 +25,15 @@ def __init__(self, brains, time=True, camera=False, colorbar=True, picking=False if camera: self.link_cameras() + # A camera observer (below) holds a reference back to this + # _LinkViewer (and hence to all linked brains) that gc.collect() + # cannot resolve on its own: VTK's AddObserver keeps the callback + # alive from the C side, outside of Python's reference-cycle + # tracking. So unlink it whenever any linked brain closes (harmless + # no-op if camera linking was never enabled). + for brain in brains: + brain._renderer._window_close_connect(self._unlink_camera) + events_to_link = [] if time: events_to_link.append("time_change") @@ -92,7 +102,14 @@ def _update_camera(vtk_picker, event): brain.plotter.update() camera = self.leader.plotter.camera - _add_camera_callback(camera, _update_camera) + tag = _add_camera_callback(camera, _update_camera) + self._camera_observer = (camera, tag) for brain in self.brains: for renderer in brain.plotter.renderers: renderer.camera = camera + + def _unlink_camera(self): + if self._camera_observer is not None: + camera, tag = self._camera_observer + camera.RemoveObserver(tag) + self._camera_observer = None diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 8f35083f2b0..755bdecf24b 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -1222,7 +1222,7 @@ def _process_events(plotter): def _add_camera_callback(camera, callback): - camera.AddObserver(vtkCommand.ModifiedEvent, callback) + return camera.AddObserver(vtkCommand.ModifiedEvent, callback) def _arrow_glyph(grid, factor): From 1937671e223d8abf554124a57aad7476b1985b1b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 01:12:40 -0400 Subject: [PATCH 06/31] FIX: Revert --- mne/viz/_brain/_linkviewer.py | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/mne/viz/_brain/_linkviewer.py b/mne/viz/_brain/_linkviewer.py index bcfcfa598d8..f642e2bff99 100644 --- a/mne/viz/_brain/_linkviewer.py +++ b/mne/viz/_brain/_linkviewer.py @@ -14,7 +14,6 @@ class _LinkViewer: def __init__(self, brains, time=True, camera=False, colorbar=True, picking=False): self.brains = brains self.leader = self.brains[0] # select a brain as leader - self._camera_observer = None # check time infos times = [brain._times for brain in brains] @@ -25,15 +24,6 @@ def __init__(self, brains, time=True, camera=False, colorbar=True, picking=False if camera: self.link_cameras() - # A camera observer (below) holds a reference back to this - # _LinkViewer (and hence to all linked brains) that gc.collect() - # cannot resolve on its own: VTK's AddObserver keeps the callback - # alive from the C side, outside of Python's reference-cycle - # tracking. So unlink it whenever any linked brain closes (harmless - # no-op if camera linking was never enabled). - for brain in brains: - brain._renderer._window_close_connect(self._unlink_camera) - events_to_link = [] if time: events_to_link.append("time_change") @@ -102,14 +92,7 @@ def _update_camera(vtk_picker, event): brain.plotter.update() camera = self.leader.plotter.camera - tag = _add_camera_callback(camera, _update_camera) - self._camera_observer = (camera, tag) + _add_camera_callback(camera, _update_camera) for brain in self.brains: for renderer in brain.plotter.renderers: renderer.camera = camera - - def _unlink_camera(self): - if self._camera_observer is not None: - camera, tag = self._camera_observer - camera.RemoveObserver(tag) - self._camera_observer = None From 7fe16c795a397952367a4253f8a44cd8ff7589eb Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 01:28:27 -0400 Subject: [PATCH 07/31] TST: Ping [circle full] From dde324f78dc2e6ff19a8359edf11b8f42d0c75f5 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 10:07:04 -0400 Subject: [PATCH 08/31] FIX: Info --- mne/utils/misc.py | 128 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 34 deletions(-) diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 27e995beebf..c82750ebee9 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -396,6 +396,90 @@ def _gc_collect_once(request=None): gc.collect() +def _safe_repr(obj, *, maxlen=100): + """Get a repr that cannot raise (e.g., on a deleted VTK/Qt C++ object).""" + try: + rep = repr(obj) + except Exception as exc: + return f"" + return rep[:maxlen].replace("\n", " ") + + +def _dict_owner(d): + """Find the object whose __dict__ (or similar) *is* d, if any.""" + for o in gc.get_referrers(d): + if getattr(o, "__dict__", None) is d: + return o + return None + + +def _describe_referrer(r, referent): + """Build a short, safe description of r, something that refers to referent. + + Returns + ------- + desc : str + Human-readable, safe description of r. + opaque : bool + Whether r is an unattributed container worth recursing into further + to find what is actually anchoring the reference (as opposed to + already being a well-identified owner, e.g. a specific instance's + ``__dict__`` or a bound method, where recursing further just adds + uninformative noise about how Python happens to store that owner). + """ + if inspect.ismethod(r): + desc = f"bound method {r.__func__.__qualname__} of {_safe_repr(r.__self__)}" + return desc, False + name = _fullname(r, referent=referent) + if isinstance(r, dict): + owner = _dict_owner(r) + if owner is not None: + return f"{_fullname(owner)}.__dict__ ({name})", False + return f"{name} (len={len(r)})", True + if isinstance(r, list | tuple): + return f"{name} (len={len(r)})", True + rep = _safe_repr(r) + if rep.startswith(" 0 + extra.append(f"Brain._cleaned = {getattr(obj, '_cleaned', None)}") + lines, has_referrers = _referrer_chain( + obj, exclude_ids={id(objs), id(ref), id(globals())} + ) + if has_referrers: + ref.extend(extra) + ref.append(f"{_fullname(obj)}:") + ref.extend(lines) + n += 1 del obj del objs assert n == 0, f"\n{n} {cls.__name__} @ {when}:\n" + "\n".join(ref) From 089a30b06225f0469cb7548e3cc34b392362da27 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 10:07:18 -0400 Subject: [PATCH 09/31] TST: Meant to full [circle full] From b8c9d4ef2d565abd3621fd186ebceee6bdb54c2a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 11:19:26 -0400 Subject: [PATCH 10/31] WIP: Try [circle full] [skip azp] [skip actions] --- mne/utils/misc.py | 114 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/mne/utils/misc.py b/mne/utils/misc.py index c82750ebee9..dea90f9d312 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -413,6 +413,28 @@ def _dict_owner(d): return None +def _module_global_name(obj): + """Find the "module.attr" name of obj if it is itself a module-level global. + + This is what lets a failure message name e.g. a long-lived module-level + registry (cache, weak-value dict, etc.) directly, which is often the + actual reason an object outlives a single test/example: a plain + ``gc.get_referrers`` walk only shows an anonymous ``dict``/``list``. + """ + for modname, mod in list(sys.modules.items()): + d = getattr(mod, "__dict__", None) + if not d: + continue + try: + items = list(d.items()) + except Exception: + continue + for key, val in items: + if val is obj: + return f"{modname}.{key}" + return None + + def _describe_referrer(r, referent): """Build a short, safe description of r, something that refers to referent. @@ -420,62 +442,106 @@ def _describe_referrer(r, referent): ------- desc : str Human-readable, safe description of r. - opaque : bool - Whether r is an unattributed container worth recursing into further - to find what is actually anchoring the reference (as opposed to - already being a well-identified owner, e.g. a specific instance's - ``__dict__`` or a bound method, where recursing further just adds - uninformative noise about how Python happens to store that owner). + next_obj : object | None + What to keep tracing referrers of (``None`` to stop here). This is + usually ``r`` itself, but for e.g. an instance's ``__dict__`` it's + the owning instance (tracing the dict's own referrers is normally + just uninformative interpreter-internal noise), and for a + module-level global it's ``None`` (a named global is already a + fully-explained anchor; nothing more useful to say). """ + global_name = _module_global_name(r) if inspect.ismethod(r): desc = f"bound method {r.__func__.__qualname__} of {_safe_repr(r.__self__)}" - return desc, False + return desc, r name = _fullname(r, referent=referent) if isinstance(r, dict): owner = _dict_owner(r) if owner is not None: - return f"{_fullname(owner)}.__dict__ ({name})", False - return f"{name} (len={len(r)})", True + return f"{_fullname(owner)}.__dict__ ({name})", owner + if global_name is not None: + return f"{global_name} (module-level dict, len={len(r)})", None + return f"{name} (len={len(r)})", r if isinstance(r, list | tuple): - return f"{name} (len={len(r)})", True + if global_name is not None: + return f"{global_name} (module-level {name}, len={len(r)})", None + return f"{name} (len={len(r)})", r + if global_name is not None: + return f"{global_name} ({_safe_repr(r)})", None rep = _safe_repr(r) if rep.startswith("= max_lines: + lines.append(" " * depth + "- ... (truncated)") + return any_shown + if inspect.isframe(r) or id(r) in excluded: continue - seen.add(id(r)) any_shown = True - desc, opaque = _describe_referrer(r, o) + desc, next_obj = _describe_referrer(r, o) lines.append(" " * depth + "- " + desc) - if opaque and depth + 1 < max_depth: - _append_referrers(r, depth + 1, max_depth=max_depth, seen=seen, lines=lines) + if ( + next_obj is not None + and id(next_obj) not in recursed + and id(next_obj) not in excluded + and depth + 1 < max_depth + ): + recursed.add(id(next_obj)) + _append_referrers( + next_obj, + depth + 1, + max_depth=max_depth, + max_lines=max_lines, + excluded=excluded, + recursed=recursed, + lines=lines, + ) del r + del refs return any_shown -def _referrer_chain(obj, *, max_depth=3, exclude_ids=()): +def _referrer_chain(obj, *, max_depth=5, max_lines=40, exclude_ids=()): """Describe, recursively, what holds references to obj. Referrers are walked (and indented) up to ``max_depth`` hops so that a - leaked object's actual anchor (e.g. some instance's ``__dict__`` several + leaked object's actual anchor (e.g. a module-level registry several containers away) is visible directly in the failure message, instead of just the immediate (often uninformative, e.g. a bare ``list``) referrer. """ lines = list() - seen = set(exclude_ids) - seen.add(id(obj)) + excluded = set(exclude_ids) + recursed = {id(obj)} has_referrers = _append_referrers( - obj, 0, max_depth=max_depth, seen=seen, lines=lines + obj, + 0, + max_depth=max_depth, + max_lines=max_lines, + excluded=excluded, + recursed=recursed, + lines=lines, ) return lines, has_referrers From ddb02d165ec50a28ed2c942ef70ce747dd188dfe Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 11:34:04 -0400 Subject: [PATCH 11/31] FIX: Better naming [circle full] [skip azp] [skip actions] --- mne/utils/misc.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mne/utils/misc.py b/mne/utils/misc.py index dea90f9d312..5fd79ae00ae 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -354,11 +354,16 @@ def _file_like(obj): def _fullname(obj, *, referent=None): - klass = obj.__class__ - module = klass.__module__ - name = klass.__qualname__ - if module != "builtins": - name = f"{module}.{name}" + if inspect.ismodule(obj): + # Otherwise every module shows up identically as just "module", + # which is exactly the info we need to tell which one is which. + name = getattr(obj, "__name__", "") + else: + 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): From 8f0c6751bf969ef65456ed9a63a826c5142f8c11 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 11:41:58 -0400 Subject: [PATCH 12/31] FIX:? [circle full] --- doc/sphinxext/mne_doc_utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/doc/sphinxext/mne_doc_utils.py b/doc/sphinxext/mne_doc_utils.py index 8e7415b3375..89941fec0fc 100644 --- a/doc/sphinxext/mne_doc_utils.py +++ b/doc/sphinxext/mne_doc_utils.py @@ -7,6 +7,7 @@ import functools import gc import os +import sys import time import warnings from pathlib import Path @@ -177,6 +178,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 :( From 7f2996db7c44aae700b7ebcec460ec1560b7aae2 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 15:30:35 -0400 Subject: [PATCH 13/31] FIX: Fixes [circle full] --- mne/utils/misc.py | 140 +++++++++++++++++++++-------------- mne/utils/tests/test_misc.py | 42 ++++++++++- mne/viz/_brain/_brain.py | 7 +- 3 files changed, 133 insertions(+), 56 deletions(-) diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 5fd79ae00ae..835e9f06de8 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -353,34 +353,34 @@ def _file_like(obj): return all(callable(getattr(obj, name, None)) for name in ("read", "seek")) -def _fullname(obj, *, referent=None): +def _fullname(obj): if inspect.ismodule(obj): # Otherwise every module shows up identically as just "module", # which is exactly the info we need to tell which one is which. - name = getattr(obj, "__name__", "") - else: - 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 getattr(obj, "__name__", "") + klass = obj.__class__ + module = klass.__module__ + name = klass.__qualname__ + if module != "builtins": + name = f"{module}.{name}" return name +def _key_suffix(obj, referent): + """Return the ``[...]``-like Python-syntax suffix to reach referent from obj.""" + if isinstance(obj, list | tuple): + for ii, item in enumerate(obj): + if item is referent: + return f"[{ii}]" + elif isinstance(obj, dict): + for key, value in obj.items(): + if key is referent: + return "-key" + if value is referent: + return f"[{key!r}]" + return "" + + def _gc_collect_once(request=None): """Call gc.collect(), deduplicated once per test item if given a request. @@ -441,7 +441,13 @@ def _module_global_name(obj): def _describe_referrer(r, referent): - """Build a short, safe description of r, something that refers to referent. + """Build a "name: type = repr"-style description of r, which refers to referent. + + Mirroring a Python variable declaration keeps every referrer kind + parseable the same way: a name (the best Python-syntax expression for + reaching ``r``, falling back to its type when nothing better is known), + its type, and a repr -- for containers (dict/list/tuple) this is always + at least a length summary rather than their (possibly huge) contents. Returns ------- @@ -455,43 +461,57 @@ def _describe_referrer(r, referent): module-level global it's ``None`` (a named global is already a fully-explained anchor; nothing more useful to say). """ - global_name = _module_global_name(r) if inspect.ismethod(r): - desc = f"bound method {r.__func__.__qualname__} of {_safe_repr(r.__self__)}" - return desc, r - name = _fullname(r, referent=referent) + name = r.__func__.__qualname__ + return f"{name}: method = {_safe_repr(r.__self__)}", r + if inspect.isfunction(r): + return f"{r.__qualname__}: function = {_safe_repr(r)}", r + if inspect.ismodule(r): + return f"{_fullname(r)}: module = {_safe_repr(r)}", r if isinstance(r, dict): + suffix = _key_suffix(r, referent) owner = _dict_owner(r) if owner is not None: - return f"{_fullname(owner)}.__dict__ ({name})", owner + # e.g. "some.module.SomeClass.__dict__['attr']: dict = " + name = f"{_fullname(owner)}.__dict__{suffix}" + return f"{name}: dict = ", owner + global_name = _module_global_name(r) if global_name is not None: - return f"{global_name} (module-level dict, len={len(r)})", None - return f"{name} (len={len(r)})", r + # e.g. "sys.modules['__main__']: dict = " + return f"{global_name}{suffix}: dict = ", None + return f"dict{suffix}: dict = ", r if isinstance(r, list | tuple): + suffix = _key_suffix(r, referent) + kind = "list" if isinstance(r, list) else "tuple" + global_name = _module_global_name(r) if global_name is not None: - return f"{global_name} (module-level {name}, len={len(r)})", None - return f"{name} (len={len(r)})", r + return f"{global_name}{suffix}: {kind} = ", None + return f"{kind}{suffix}: {kind} = ", r + global_name = _module_global_name(r) if global_name is not None: - return f"{global_name} ({_safe_repr(r)})", None + return f"{global_name}: {_fullname(r)} = {_safe_repr(r)}", None rep = _safe_repr(r) if rep.startswith("= max_lines: - lines.append(" " * depth + "- ... (truncated)") - return any_shown + if count[0] >= max_lines: + nodes.append(("... (truncated)", [])) + return nodes if inspect.isframe(r) or id(r) in excluded: continue - any_shown = True + count[0] += 1 desc, next_obj = _describe_referrer(r, o) - lines.append(" " * depth + "- " + desc) + children = list() if ( next_obj is not None and id(next_obj) not in recursed @@ -514,41 +534,53 @@ def _append_referrers(o, depth, *, max_depth, max_lines, excluded, recursed, lin and depth + 1 < max_depth ): recursed.add(id(next_obj)) - _append_referrers( + children = _referrer_tree( next_obj, depth + 1, max_depth=max_depth, max_lines=max_lines, + count=count, excluded=excluded, recursed=recursed, - lines=lines, ) + nodes.append((desc, children)) del r del refs - return any_shown + return nodes + + +def _render_tree(nodes, prefix=""): + """Render a (description, children) tree using box-drawing characters.""" + lines = list() + for i, (desc, children) in enumerate(nodes): + last = i == len(nodes) - 1 + lines.append(prefix + ("└── " if last else "├── ") + desc) + child_prefix = prefix + (" " if last else "│ ") + lines.extend(_render_tree(children, child_prefix)) + return lines def _referrer_chain(obj, *, max_depth=5, max_lines=40, exclude_ids=()): """Describe, recursively, what holds references to obj. - Referrers are walked (and indented) up to ``max_depth`` hops so that a - leaked object's actual anchor (e.g. a module-level registry several - containers away) is visible directly in the failure message, instead of - just the immediate (often uninformative, e.g. a bare ``list``) referrer. + Referrers are walked up to ``max_depth`` hops and rendered as a tree, so + that a leaked object's actual anchor (e.g. a module-level registry + several containers away) is visible directly in the failure message, + instead of just the immediate (often uninformative, e.g. a bare + ``list``) referrer. """ - lines = list() excluded = set(exclude_ids) recursed = {id(obj)} - has_referrers = _append_referrers( + nodes = _referrer_tree( obj, 0, max_depth=max_depth, max_lines=max_lines, + count=[0], excluded=excluded, recursed=recursed, - lines=lines, ) - return lines, has_referrers + return _render_tree(nodes), len(nodes) > 0 def _assert_no_instances(cls, when="", *, request=None, objs=None): diff --git a/mne/utils/tests/test_misc.py b/mne/utils/tests/test_misc.py index c3bc63dea76..b4e344d0a35 100644 --- a/mne/utils/tests/test_misc.py +++ b/mne/utils/tests/test_misc.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import gc import os import subprocess import sys @@ -10,7 +11,13 @@ 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_sizeof_fmt(): @@ -159,3 +166,36 @@ def test_clean_names(): ch_names_clean = _clean_names(ch_names, before_dash=True) assert ch_names == ch_names_clean assert len(set(ch_names_clean)) == len(ch_names_clean) + + +class _Leaky: + """Something to track instances of.""" + + +class _Holder: + """Something that can hold a reference to a _Leaky.""" + + def __init__(self, obj): + self.thing = obj + + +# Must be module-level (not local to the test) so it can be found by name, +# like a real long-lived registry (e.g. pyvista's `_ALL_PLOTTERS`) would be. +_registry = {} + + +def test_assert_no_instances_message(): + """Test that _assert_no_instances explains what's holding a reference.""" + + def make_leak(): + _registry["key"] = _Holder(_Leaky()) + + make_leak() + gc.collect() + with pytest.raises(AssertionError, match="1 _Leaky") as excinfo: + _assert_no_instances(_Leaky, "test") + msg = str(excinfo.value) + # the referrer chain should explain *what* holds the reference: + assert "_Holder: " in msg # ... something holding onto our _Leaky... + assert "_registry['key']: dict = " in msg # ... which lives here + del _registry["key"] diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index c1c0112f0f0..4d64eacdd5f 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -3068,7 +3068,12 @@ def add_annotation( ) if hover: - obs = self.plotter.AddObserver("MouseMoveEvent", self._on_annotation_hover) + + @_auto_weakref + def on_annotation_hover(iren, event): + self._on_annotation_hover(iren, event) + + obs = self.plotter.AddObserver("MouseMoveEvent", on_annotation_hover) for hemi in hemis: caption = self._create_caption() self._annots[hemi][-1].update(caption=caption, obs=obs) From 4a7e8bf5f151bb6a43554617754e385cb6235890 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 18:56:30 -0400 Subject: [PATCH 14/31] FIX: instantiated mesh --- mne/gui/tests/test_coreg.py | 6 +- mne/viz/_3d.py | 104 ++++++++++++++++-------- mne/viz/backends/_abstract.py | 61 ++++++++++++++ mne/viz/backends/_pyvista.py | 49 +++++++++++ mne/viz/backends/tests/test_renderer.py | 17 ++++ mne/viz/tests/test_3d.py | 24 +++++- 6 files changed, 221 insertions(+), 40 deletions(-) diff --git a/mne/gui/tests/test_coreg.py b/mne/gui/tests/test_coreg.py index 8562cb45b23..13ef01ca827 100644 --- a/mne/gui/tests/test_coreg.py +++ b/mne/gui/tests/test_coreg.py @@ -263,8 +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 - # coil surfaces sharing a color are merged into a single mesh/actor - assert len(coreg._actors["sensors"]) == 1 + # 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 diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 26ef2b50493..0e702adec63 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1103,7 +1103,8 @@ def _ch_pos_in_coord_frame(info, to_cf_t, warn_meg=True, verbose=None): # add sensors and detectors too for fNIRS type_slices.update(sources=slice(3, 6), detectors=slice(6, 9)) for type_name, type_slice in type_slices.items(): - if ch_type in _MEG_CH_TYPES_SPLIT + ("ref_meg",): + is_meg = ch_type in _MEG_CH_TYPES_SPLIT + ("ref_meg",) + if is_meg: coil_trans = _loc_to_coil_trans(info["chs"][idx]["loc"]) # Here we prefer accurate geometry in case we need to # ConvexHull the coil, we want true 3D geometry (and not, for @@ -1117,11 +1118,15 @@ def _ch_pos_in_coord_frame(info, to_cf_t, warn_meg=True, verbose=None): coil = _create_meg_coils(this_coil, acc="normal", coilset=coilset)[ 0 ] - # store verts as ch_coord - ch_coord, triangles = _sensor_shape(coil) - ch_coord = apply_trans(coil_trans, ch_coord) - if len(ch_coord) == 0 and warn_meg: + # Keep the local (template) geometry and the per-channel + # transform separate (rather than baking into absolute + # vertex positions) so that channels sharing a coil shape + # can share one template/actor when rendered. + local_rr, triangles, extra_z = _sensor_shape(coil) + if len(local_rr) == 0 and warn_meg: warn(f"MEG sensor {info.ch_names[idx]} not found.") + coil_trans = coil_trans.copy() + coil_trans[:3, 3] += coil_trans[:3, :3] @ [0, 0, extra_z] else: ch_coord = info["chs"][idx]["loc"][type_slice] ch_coord_frame = info["chs"][idx]["coord_frame"] @@ -1139,10 +1144,21 @@ def _ch_pos_in_coord_frame(info, to_cf_t, warn_meg=True, verbose=None): if ch_coord_frame == FIFF.FIFFV_COORD_UNKNOWN: unknown_chs.append(info.ch_names[idx]) ch_coord_frame = FIFF.FIFFV_COORD_HEAD - ch_coord = apply_trans(to_cf_t[_frame_to_str[ch_coord_frame]], ch_coord) - if ch_type in _MEG_CH_TYPES_SPLIT + ("ref_meg",): - chs[type_name][info.ch_names[idx]] = (ch_coord, triangles) + if is_meg: + frame_trans = to_cf_t[_frame_to_str[ch_coord_frame]]["trans"] + transform = frame_trans @ coil_trans + position = transform[:3, 3] + quat = rot_to_quat(transform[:3, :3]) + shape_key = (local_rr.round(10).tobytes(), triangles.tobytes()) + chs[type_name][info.ch_names[idx]] = ( + local_rr, + triangles, + position, + quat, + shape_key, + ) else: + ch_coord = apply_trans(to_cf_t[_frame_to_str[ch_coord_frame]], ch_coord) chs[type_name][info.ch_names[idx]] = ch_coord if unknown_chs: warn( @@ -1570,7 +1586,14 @@ def _plot_sensors_3d( plot_sensors = False # plot sensors if isinstance(ch_coord, tuple): # is meg, plot coil - ch_coord = dict(rr=ch_coord[0] * unit_scalar, tris=ch_coord[1]) + local_rr, triangles, position, quat, shape_key = ch_coord + ch_coord = dict( + rr=local_rr * unit_scalar, + tris=triangles, + position=position * unit_scalar, + quat=quat, + shape_key=shape_key, + ) if plot_sensors: if ch_type == "eeg": if "original" in eeg: @@ -1648,18 +1671,24 @@ def _plot_sensors_3d( if isinstance(sens_loc[0], dict): # meg coil if len(colors) == 1: colors = [colors[0]] * len(sens_loc) - # Merge same-color coils into one mesh/actor instead of one per - # coil (can number in the hundreds), since per-call overhead - # dominates. Could also use a GPU-instanced vtkGlyph3DMapper - # (quaternion mode), but that's a larger refactor for little gain. + colors = np.array(colors, float) + colors[:, 3] *= this_alpha + # Group coils by shape: one GPU-instanced actor per shape groups = defaultdict(list) - for surface, color in zip(sens_loc, colors): - groups[tuple(color)].append(surface) - for color, surfaces in groups.items(): - actor, _ = renderer.surface( - surface=_merge_surfaces(surfaces), - color=color[:3], - opacity=this_alpha * color[3], + for si, surface in enumerate(sens_loc): + if len(surface["rr"]) == 0: + continue # coil geometry not found (already warned above) + groups[surface["shape_key"]].append(si) + for idxs in groups.values(): + template = sens_loc[idxs[0]] + positions = np.array([sens_loc[i]["position"] for i in idxs]) + quats = np.array([sens_loc[i]["quat"] for i in idxs]) + actor, _ = renderer.instanced_mesh( + rr=template["rr"], + tris=template["tris"], + positions=positions, + quats=quats, + colors=colors[idxs], backface_culling=False, # visible from all sides ) actors[ch_type].append(actor) @@ -1768,18 +1797,6 @@ def _plot_sensors_3d( return actors -def _merge_surfaces(surfaces): - """Merge a list of surface dicts (rr, tris) into a single surface dict.""" - rrs = list() - tris = list() - offset = 0 - for surface in surfaces: - rrs.append(surface["rr"]) - tris.append(surface["tris"] + offset) - offset += len(surface["rr"]) - return dict(rr=np.concatenate(rrs), tris=np.concatenate(tris)) - - def _make_tris_fan(n_vert): """Make tris given a number of vertices of a circle-like obj.""" tris = np.zeros((n_vert - 2, 3), int) @@ -1789,13 +1806,30 @@ def _make_tris_fan(n_vert): def _sensor_shape(coil): - """Get the sensor shape vertices.""" + """Get the sensor shape vertices. + + Returns + ------- + rrs : ndarray, shape (n_vert, 3) + Vertex coordinates in the local coil frame. A pure function of coil + type/size/base only (never per-channel), so that channels sharing a + coil type can share one template mesh. + tris : ndarray, shape (n_tri, 3) + Triangle indices. + extra_z : float + Extra translation (in local +Z) for this specific channel on top of + the shared template -- used only to keep paired Neuromag planar + gradiometer channels (e.g. MEG0112/MEG0113) from z-fighting on + screen. Kept separate from ``rrs`` so it can be folded into a + per-channel transform instead of the (otherwise shared) template. + """ try: from scipy.spatial import QhullError except ImportError: # scipy < 1.8 from scipy.spatial.qhull import QhullError id_ = coil["type"] & 0xFFFF z_value = 0 + extra_z = 0.0 # Square figure eight if id_ in ( FIFF.FIFFV_COIL_NM_122, @@ -1822,7 +1856,7 @@ def _sensor_shape(coil): (_make_tris_fan(4), _make_tris_fan(4)[:, ::-1] + 4), axis=0 ) # Offset for visibility (using heuristic for sanely named Neuromag coils) - z_value = 0.001 * (1 + coil["chname"].endswith("2")) + extra_z = 0.001 * (1 + coil["chname"].endswith("2")) # Square elif id_ in ( FIFF.FIFFV_COIL_POINT_MAGNETOMETER, @@ -1899,7 +1933,7 @@ def _sensor_shape(coil): if z_value is not None: rrs = np.pad(rrs, ((0, 0), (0, 1)), mode="constant", constant_values=z_value) assert rrs.ndim == 2 and rrs.shape[1] == 3 - return rrs, tris + return rrs, tris, extra_z def _process_clim(clim, colormap, transparent, data=0.0, allow_pos_lims=True): diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 72c665b62d4..5f5b78f37d3 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -471,6 +471,67 @@ def quiver3d( """ pass + @classmethod + @abstractmethod + def instanced_mesh( + self, + rr, + tris, + positions, + quats, + colors, + opacity=1.0, + backface_culling=False, + name=None, + ): + """Add one mesh GPU-instanced at multiple positions/orientations. + + Unlike :meth:`quiver3d` or :meth:`sphere`, which bake a merged, + static glyph mesh, this draws one copy of a single template mesh at + each of ``n_instances`` locations using per-instance position, + orientation, and color that can be changed later without rebuilding + the geometry (see ``mesh`` in "Returns" below). + + Parameters + ---------- + rr : array, shape (n_vertices, 3) + The vertices of the template mesh, in the local (object) frame + shared by all instances. + tris : array, shape (n_tris, 3) + The triangles of the template mesh. + positions : array, shape (n_instances, 3) + The position of each instance. + quats : array, shape (n_instances, 3) + The orientation of each instance as a unit quaternion, given as + only the ``(x, y, z)`` vector part (``w`` omitted, recoverable + as ``sqrt(max(1 - x**2 - y**2 - z**2, 0))``) -- the same + convention used by :func:`~mne.transforms.rot_to_quat` / + :func:`~mne.transforms.quat_to_rot`. + colors : array, shape (n_instances, 4) + The per-instance RGBA color (float values between 0 and 1) to + use for each instance. Per-instance alpha (the fourth column) + is respected. + opacity : float + A uniform opacity multiplier applied on top of the per-instance + alpha values in ``colors``. + backface_culling : bool + If True, enable backface culling on the mesh. + name : str | None + Name of the mesh. + + Returns + ------- + actor : + The actor in the scene. + mesh : + The point cloud (one point per instance) backing the glyph + mapper. Its per-instance color and orientation arrays can be + mutated in place (followed by a render update) to recolor or + re-orient individual instances live, without rebuilding the + actor or its geometry. + """ + pass + @classmethod @abstractmethod def text2d( diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 755bdecf24b..544de54a56b 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -39,6 +39,7 @@ vtkColorTransferFunction, vtkCoordinate, vtkDataSetMapper, + vtkGlyph3DMapper, vtkMapper, vtkPolyDataMapper, vtkVolume, @@ -687,6 +688,47 @@ def quiver3d( ) return actor, mesh + def instanced_mesh( + self, + rr, + tris, + positions, + quats, + colors, + opacity=1.0, + backface_culling=False, + *, + name=None, + ): + faces = np.c_[np.full(len(tris), 3), tris] + geom = PolyData(np.asarray(rr, float), faces) + _compute_normals(geom) + + cloud = PolyData(np.asarray(positions, float).copy()) + cloud.point_data["orientation"] = _quat_to_vtk_wxyz(np.asarray(quats, float)) + cloud.point_data["colors"] = (np.asarray(colors, float) * 255).astype(np.uint8) + + mapper = vtkGlyph3DMapper() + mapper.SetInputData(cloud) + mapper.SetSourceData(geom) + mapper.SetOrientationArray("orientation") + mapper.SetOrientationModeToQuaternion() + mapper.ScalingOff() # coil size is baked into rr; no per-instance scale + mapper.SetScalarModeToUsePointFieldData() + mapper.SelectColorArray("colors") + mapper.SetColorModeToDirectScalars() + + actor = self._actor(mapper) + prop = actor.GetProperty() + prop.SetOpacity(opacity) + prop.SetBackfaceCulling(backface_culling) + if self.smooth_shading and "Normals" in geom.point_data: + prop.SetInterpolationToPhong() + self.plotter.add_actor( + actor, name=name, render=False, reset_camera=False, pickable=True + ) + return actor, cloud + def text2d( self, x_window, @@ -1038,6 +1080,13 @@ def _compute_normals(mesh): ) +def _quat_to_vtk_wxyz(quat): + """Convert MNE's (..., 3) unit quaternions to VTK-order (w, x, y, z).""" + assert quat.ndim >= 2 and quat.shape[-1] == 3, quat.shape + w = np.sqrt(np.clip(1.0 - (quat * quat).sum(-1), 0.0, 1.0)) + return np.concatenate([w[..., np.newaxis], quat], axis=-1) + + def _add_mesh(plotter, **kwargs): """Patch PyVista add_mesh.""" mesh = kwargs.get("mesh") diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 399aced2cfd..661eacf9a2d 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -10,6 +10,7 @@ import pytest from matplotlib.font_manager import findfont +from mne.transforms import 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 @@ -150,6 +151,22 @@ def test_3d_backend(renderer): with pytest.raises(ValueError, match="Invalid value"): rend.quiver3d(mode="foo", **kwargs) + # use instanced_mesh + inst_positions = np.array([[0.0, 0.0, 0.0], [tet_size, 0.0, 0.0]]) + inst_quats = np.array([rot_to_quat(np.eye(3)), rot_to_quat(np.eye(3))]) + inst_colors = np.array([[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]]) + _, inst_cloud = rend.instanced_mesh( + rr=sph_center * sph_scale, + tris=tet_indices, + positions=inst_positions, + quats=inst_quats, + colors=inst_colors, + ) + # colors can be updated in place (e.g. for future sensor + # highlighting/hover) without rebuilding the actor or its geometry + inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] + inst_cloud.Modified() + # use tube rend.tube(origin=np.array([[0, 0, 0]]), destination=np.array([[0, 1, 0]])) _, tube = rend.tube( diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index f30ccd803ce..4e6cfce4504 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -485,9 +485,27 @@ def test_plot_alignment_meg(renderer, system): sensor_colors=sensor_colors, ) assert isinstance(fig, Figure3D) - # 1 actor per distinct coil color (merged for speed) + helmet + head - n_colors = 2 if system == "KIT" else 1 # KIT also has a separate ref_meg color - _assert_n_actors(fig, renderer, n_colors + 2) + # one actor per distinct coil shape + helmet + head + # Neuromag has 2 shapes, CTF and BTi expose only mags, KIT's mag + ref mag are same + n_shapes = 1 if system in ("CTF", "BTi") else 2 + _assert_n_actors(fig, renderer, n_shapes + 2) + + if system == "Neuromag": + # Passing fully-random per-channel colors should not blow up the + # actor count (proves per-channel MEG coil coloring no longer + # requires one actor per unique color). + rng = np.random.default_rng(0) + n_meg = len(pick_types(this_info, meg=True)) + fig2 = plot_alignment( + this_info, + read_trans(trans_fname), + subject="sample", + subjects_dir=subjects_dir, + meg=meg, + eeg=False, + sensor_colors=dict(meg=rng.random((n_meg, 4))), + ) + _assert_n_actors(fig2, renderer, n_shapes + 2) @testing.requires_testing_data From 6b2391cca1dfd43af4444f7f18b1f67d5147749d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 19:02:37 -0400 Subject: [PATCH 15/31] FIX: Switch to refleak [circle full] --- mne/conftest.py | 6 +- mne/utils/misc.py | 259 +--------------------------------------------- pyproject.toml | 2 + 3 files changed, 8 insertions(+), 259 deletions(-) diff --git a/mne/conftest.py b/mne/conftest.py index bf4590c8734..11a6bae8f07 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -22,6 +22,7 @@ import pytest from packaging.version import Version from pytest import StashKey, register_assert_rewrite +from refleak import gc_collect_once # Any `assert` statements in our testing functions should be verbose versions register_assert_rewrite("mne.utils._testing") @@ -41,7 +42,6 @@ _assert_no_instances, _check_qt_version, _chmod_rw_R, - _gc_collect_once, _pl, _record_warnings, _TempDir, @@ -632,7 +632,7 @@ def triaxial_evoked(triaxial_raw): def garbage_collect(request): """Garbage collect on exit.""" yield - _gc_collect_once(request) + gc_collect_once(request) @pytest.fixture @@ -1084,7 +1084,7 @@ def brain_gc(request): close_func() if not _test_passed(request): return - _gc_collect_once(request) + 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. diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 835e9f06de8..52ad240e7e6 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -5,7 +5,6 @@ # Copyright the MNE-Python contributors. import fnmatch -import gc import hashlib import inspect import os @@ -353,263 +352,11 @@ def _file_like(obj): return all(callable(getattr(obj, name, None)) for name in ("read", "seek")) -def _fullname(obj): - if inspect.ismodule(obj): - # Otherwise every module shows up identically as just "module", - # which is exactly the info we need to tell which one is which. - return getattr(obj, "__name__", "") - klass = obj.__class__ - module = klass.__module__ - name = klass.__qualname__ - if module != "builtins": - name = f"{module}.{name}" - return name - - -def _key_suffix(obj, referent): - """Return the ``[...]``-like Python-syntax suffix to reach referent from obj.""" - if isinstance(obj, list | tuple): - for ii, item in enumerate(obj): - if item is referent: - return f"[{ii}]" - elif isinstance(obj, dict): - for key, value in obj.items(): - if key is referent: - return "-key" - if value is referent: - return f"[{key!r}]" - return "" - - -def _gc_collect_once(request=None): - """Call gc.collect(), deduplicated once per test item if given a request. - - ``gc.collect()`` cost scales with the number of tracked objects in the - whole process, so when several independent test fixtures each want a - fresh collect during the same test's teardown, doing it more than once - is a significant and unnecessary fraction of total test time. When - ``request`` (a pytest fixture request) is given, only the first call - for a given test item actually collects; later calls are no-ops. - """ - if request is None: - gc.collect() - return - node = request.node - if getattr(node, "_mne_gc_collected", False): - return - node._mne_gc_collected = True - gc.collect() - - -def _safe_repr(obj, *, maxlen=100): - """Get a repr that cannot raise (e.g., on a deleted VTK/Qt C++ object).""" - try: - rep = repr(obj) - except Exception as exc: - return f"" - return rep[:maxlen].replace("\n", " ") - - -def _dict_owner(d): - """Find the object whose __dict__ (or similar) *is* d, if any.""" - for o in gc.get_referrers(d): - if getattr(o, "__dict__", None) is d: - return o - return None - - -def _module_global_name(obj): - """Find the "module.attr" name of obj if it is itself a module-level global. - - This is what lets a failure message name e.g. a long-lived module-level - registry (cache, weak-value dict, etc.) directly, which is often the - actual reason an object outlives a single test/example: a plain - ``gc.get_referrers`` walk only shows an anonymous ``dict``/``list``. - """ - for modname, mod in list(sys.modules.items()): - d = getattr(mod, "__dict__", None) - if not d: - continue - try: - items = list(d.items()) - except Exception: - continue - for key, val in items: - if val is obj: - return f"{modname}.{key}" - return None - - -def _describe_referrer(r, referent): - """Build a "name: type = repr"-style description of r, which refers to referent. - - Mirroring a Python variable declaration keeps every referrer kind - parseable the same way: a name (the best Python-syntax expression for - reaching ``r``, falling back to its type when nothing better is known), - its type, and a repr -- for containers (dict/list/tuple) this is always - at least a length summary rather than their (possibly huge) contents. - - Returns - ------- - desc : str - Human-readable, safe description of r. - next_obj : object | None - What to keep tracing referrers of (``None`` to stop here). This is - usually ``r`` itself, but for e.g. an instance's ``__dict__`` it's - the owning instance (tracing the dict's own referrers is normally - just uninformative interpreter-internal noise), and for a - module-level global it's ``None`` (a named global is already a - fully-explained anchor; nothing more useful to say). - """ - if inspect.ismethod(r): - name = r.__func__.__qualname__ - return f"{name}: method = {_safe_repr(r.__self__)}", r - if inspect.isfunction(r): - return f"{r.__qualname__}: function = {_safe_repr(r)}", r - if inspect.ismodule(r): - return f"{_fullname(r)}: module = {_safe_repr(r)}", r - if isinstance(r, dict): - suffix = _key_suffix(r, referent) - owner = _dict_owner(r) - if owner is not None: - # e.g. "some.module.SomeClass.__dict__['attr']: dict = " - name = f"{_fullname(owner)}.__dict__{suffix}" - return f"{name}: dict = ", owner - global_name = _module_global_name(r) - if global_name is not None: - # e.g. "sys.modules['__main__']: dict = " - return f"{global_name}{suffix}: dict = ", None - return f"dict{suffix}: dict = ", r - if isinstance(r, list | tuple): - suffix = _key_suffix(r, referent) - kind = "list" if isinstance(r, list) else "tuple" - global_name = _module_global_name(r) - if global_name is not None: - return f"{global_name}{suffix}: {kind} = ", None - return f"{kind}{suffix}: {kind} = ", r - global_name = _module_global_name(r) - if global_name is not None: - return f"{global_name}: {_fullname(r)} = {_safe_repr(r)}", None - rep = _safe_repr(r) - if rep.startswith("= max_lines: - nodes.append(("... (truncated)", [])) - return nodes - if inspect.isframe(r) or id(r) in excluded: - continue - count[0] += 1 - desc, next_obj = _describe_referrer(r, o) - children = list() - if ( - next_obj is not None - and id(next_obj) not in recursed - and id(next_obj) not in excluded - and depth + 1 < max_depth - ): - recursed.add(id(next_obj)) - children = _referrer_tree( - next_obj, - depth + 1, - max_depth=max_depth, - max_lines=max_lines, - count=count, - excluded=excluded, - recursed=recursed, - ) - nodes.append((desc, children)) - del r - del refs - return nodes - - -def _render_tree(nodes, prefix=""): - """Render a (description, children) tree using box-drawing characters.""" - lines = list() - for i, (desc, children) in enumerate(nodes): - last = i == len(nodes) - 1 - lines.append(prefix + ("└── " if last else "├── ") + desc) - child_prefix = prefix + (" " if last else "│ ") - lines.extend(_render_tree(children, child_prefix)) - return lines - - -def _referrer_chain(obj, *, max_depth=5, max_lines=40, exclude_ids=()): - """Describe, recursively, what holds references to obj. - - Referrers are walked up to ``max_depth`` hops and rendered as a tree, so - that a leaked object's actual anchor (e.g. a module-level registry - several containers away) is visible directly in the failure message, - instead of just the immediate (often uninformative, e.g. a bare - ``list``) referrer. - """ - excluded = set(exclude_ids) - recursed = {id(obj)} - nodes = _referrer_tree( - obj, - 0, - max_depth=max_depth, - max_lines=max_lines, - count=[0], - excluded=excluded, - recursed=recursed, - ) - return _render_tree(nodes), len(nodes) > 0 - - def _assert_no_instances(cls, when="", *, request=None, objs=None): __tracebackhide__ = True - n = 0 - ref = list() - _gc_collect_once(request) - if objs is None: - 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: - extra = list() - if cls.__name__ == "Brain": - extra.append(f"Brain._cleaned = {getattr(obj, '_cleaned', None)}") - lines, has_referrers = _referrer_chain( - obj, exclude_ids={id(objs), id(ref), id(globals())} - ) - if has_referrers: - ref.extend(extra) - ref.append(f"{_fullname(obj)}:") - ref.extend(lines) - n += 1 - del obj - del objs - assert n == 0, f"\n{n} {cls.__name__} @ {when}:\n" + "\n".join(ref) + from refleak.testing import assert_no_instances + + return assert_no_instances(cls, when=when, request=request, objs=objs) def _resource_path(submodule, filename): diff --git a/pyproject.toml b/pyproject.toml index 41926851ced..c6495792cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ doc = [ "pyvistaqt >= 0.11", # released 2023-06-30, no newer version available "pyxdf", "pyzmq != 24.0.0", + "refleak >=0.1", "scikit-learn >= 1.5", # released 2024-05-21, will become 1.6 on 2026-12-09 "seaborn >= 0.5, != 0.11.2", "selenium >= 4.27.1", @@ -53,6 +54,7 @@ test = [ "pytest-qt >= 4.3", "pytest-rerunfailures", "pytest-timeout >= 2.2", + "refleak >=0.1", "ruff >= 0.1", "twine", "vulture", From a52a8d8de4c9b780d3eb15e41d8061f9ed8f8bf8 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 19:03:27 -0400 Subject: [PATCH 16/31] FIX: Remove [circle full] --- mne/utils/tests/test_misc.py | 42 +----------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/mne/utils/tests/test_misc.py b/mne/utils/tests/test_misc.py index b4e344d0a35..c3bc63dea76 100644 --- a/mne/utils/tests/test_misc.py +++ b/mne/utils/tests/test_misc.py @@ -2,7 +2,6 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -import gc import os import subprocess import sys @@ -11,13 +10,7 @@ import pytest import mne -from mne.utils import ( - _assert_no_instances, - _clean_names, - catch_logging, - run_subprocess, - sizeof_fmt, -) +from mne.utils import _clean_names, catch_logging, run_subprocess, sizeof_fmt def test_sizeof_fmt(): @@ -166,36 +159,3 @@ def test_clean_names(): ch_names_clean = _clean_names(ch_names, before_dash=True) assert ch_names == ch_names_clean assert len(set(ch_names_clean)) == len(ch_names_clean) - - -class _Leaky: - """Something to track instances of.""" - - -class _Holder: - """Something that can hold a reference to a _Leaky.""" - - def __init__(self, obj): - self.thing = obj - - -# Must be module-level (not local to the test) so it can be found by name, -# like a real long-lived registry (e.g. pyvista's `_ALL_PLOTTERS`) would be. -_registry = {} - - -def test_assert_no_instances_message(): - """Test that _assert_no_instances explains what's holding a reference.""" - - def make_leak(): - _registry["key"] = _Holder(_Leaky()) - - make_leak() - gc.collect() - with pytest.raises(AssertionError, match="1 _Leaky") as excinfo: - _assert_no_instances(_Leaky, "test") - msg = str(excinfo.value) - # the referrer chain should explain *what* holds the reference: - assert "_Holder: " in msg # ... something holding onto our _Leaky... - assert "_registry['key']: dict = " in msg # ... which lives here - del _registry["key"] From 36fd7f52b1653553fafffbba20be4b00d3c9b11d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 19:18:59 -0400 Subject: [PATCH 17/31] FIX: Simplify [circle full] --- mne/viz/_3d.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 0e702adec63..1e06b8f9176 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1806,23 +1806,7 @@ def _make_tris_fan(n_vert): def _sensor_shape(coil): - """Get the sensor shape vertices. - - Returns - ------- - rrs : ndarray, shape (n_vert, 3) - Vertex coordinates in the local coil frame. A pure function of coil - type/size/base only (never per-channel), so that channels sharing a - coil type can share one template mesh. - tris : ndarray, shape (n_tri, 3) - Triangle indices. - extra_z : float - Extra translation (in local +Z) for this specific channel on top of - the shared template -- used only to keep paired Neuromag planar - gradiometer channels (e.g. MEG0112/MEG0113) from z-fighting on - screen. Kept separate from ``rrs`` so it can be folded into a - per-channel transform instead of the (otherwise shared) template. - """ + """Get the sensor shape vertices.""" try: from scipy.spatial import QhullError except ImportError: # scipy < 1.8 From c2930ff7b7a439f4cca2be3bc72b59718224eab2 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 19:23:43 -0400 Subject: [PATCH 18/31] FIX: Cleaner [circle full] --- mne/viz/_3d.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 1e06b8f9176..65441eba750 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1812,7 +1812,7 @@ def _sensor_shape(coil): except ImportError: # scipy < 1.8 from scipy.spatial.qhull import QhullError id_ = coil["type"] & 0xFFFF - z_value = 0 + add_z_coord = True # almost all geometry is planar extra_z = 0.0 # Square figure eight if id_ in ( @@ -1911,11 +1911,11 @@ def _sensor_shape(coil): rr_rot = rrs @ u tris = Delaunay(rr_rot[:, :2]).simplices tris = np.concatenate((tris, tris[:, ::-1])) - z_value = None + add_z_coord = False # Go from (x,y) -> (x,y,z) - if z_value is not None: - rrs = np.pad(rrs, ((0, 0), (0, 1)), mode="constant", constant_values=z_value) + if add_z_coord: + rrs = np.pad(rrs, ((0, 0), (0, 1)), mode="constant", constant_values=0.0) assert rrs.ndim == 2 and rrs.shape[1] == 3 return rrs, tris, extra_z From b16ad2dce3131da1ce4af54a4ff8a86afda3d54d Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 19:46:12 -0400 Subject: [PATCH 19/31] FIX: Import [circle full] --- mne/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/conftest.py b/mne/conftest.py index 11a6bae8f07..f126ad21f14 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -22,7 +22,7 @@ import pytest from packaging.version import Version from pytest import StashKey, register_assert_rewrite -from refleak import gc_collect_once +from refleak.testing import gc_collect_once # Any `assert` statements in our testing functions should be verbose versions register_assert_rewrite("mne.utils._testing") From 8b8be2f68a28fae381361e316851ff6115e0a935 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 20:26:14 -0400 Subject: [PATCH 20/31] FIX: Missing [circle full] --- mne/utils/__init__.pyi | 2 -- 1 file changed, 2 deletions(-) diff --git a/mne/utils/__init__.pyi b/mne/utils/__init__.pyi index ad11a950859..73a0b6e2f6f 100644 --- a/mne/utils/__init__.pyi +++ b/mne/utils/__init__.pyi @@ -72,7 +72,6 @@ __all__ = [ "_explain_exception", "_file_like", "_freq_mask", - "_gc_collect_once", "_gen_events", "_get_argvalues", "_get_blas_funcs", @@ -332,7 +331,6 @@ from .misc import ( _empty_hash, _explain_exception, _file_like, - _gc_collect_once, _get_argvalues, _pl, _resource_path, From 52ef603aae09b1512a5620f99f48d05b448ac2e5 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 20:50:31 -0400 Subject: [PATCH 21/31] FIX: More --- mne/utils/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/utils/config.py b/mne/utils/config.py index b46a8d5c9c5..8b57fd837cf 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -863,6 +863,7 @@ def sys_info( "pytest-qt", "pytest-rerunfailures", "pytest-timeout", + "refleak", "codespell", "ipython", "mypy", From dc9f63b24339aa721105013808febc3130334fec Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 21:02:19 -0400 Subject: [PATCH 22/31] FIX: Updpate --- pyproject.toml | 4 ++-- tools/pylock.ci-old.toml | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6495792cc0..03549ecb1d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ doc = [ "pyvistaqt >= 0.11", # released 2023-06-30, no newer version available "pyxdf", "pyzmq != 24.0.0", - "refleak >=0.1", + "refleak", "scikit-learn >= 1.5", # released 2024-05-21, will become 1.6 on 2026-12-09 "seaborn >= 0.5, != 0.11.2", "selenium >= 4.27.1", @@ -54,7 +54,7 @@ test = [ "pytest-qt >= 4.3", "pytest-rerunfailures", "pytest-timeout >= 2.2", - "refleak >=0.1", + "refleak", "ruff >= 0.1", "twine", "vulture", diff --git a/tools/pylock.ci-old.toml b/tools/pylock.ci-old.toml index 5060a76bf1a..3541207f0cb 100644 --- a/tools/pylock.ci-old.toml +++ b/tools/pylock.ci-old.toml @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --python 3.10 --python-platform x86_64-unknown-linux-gnu --group test --group lockfile_extras --resolution lowest-direct --format pylock.toml --output-file tools/pylock.ci-old.toml +# uv pip compile pyproject.toml --python 3.10 --python-platform x86_64-unknown-linux-gnu --group test --group lockfile_extras --resolution lowest-direct --format pylock.toml --output-file tools/pylock.ci-old.toml --no-cache lock-version = "1.0" created-by = "uv" requires-python = ">=3.10" @@ -428,6 +428,12 @@ version = "0.37.0" sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", upload-time = 2025-10-13T15:30:48Z, size = 78036, hashes = { sha256 = "44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" } } wheels = [{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", upload-time = 2025-10-13T15:30:47Z, size = 26766, hashes = { sha256 = "381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" } }] +[[packages]] +name = "refleak" +version = "0.1.1" +sdist = { url = "https://files.pythonhosted.org/packages/48/3d/b1fe0b6bd9fccf9666c40beafff4e042c5f83eaa54d5408ccc5559e782be/refleak-0.1.1.tar.gz", upload-time = 2026-07-04T00:57:10Z, size = 17584, hashes = { sha256 = "05c7ec81497c6f0dd80028fb933136a7d9b055a978f74b17f8bc787e1602b764" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/50/fe/b26d9cad0eb21bbbaf345b9543eabc7c28f093ed022a4df9b3027df54bcd/refleak-0.1.1-py3-none-any.whl", upload-time = 2026-07-04T00:57:09Z, size = 11937, hashes = { sha256 = "aafe8c1141b59df7c10eaa9bdf509ddefcf00624be644118adb678c4af30c38f" } }] + [[packages]] name = "requests" version = "2.32.5" @@ -627,9 +633,3 @@ name = "webob" version = "1.8.9" sdist = { url = "https://files.pythonhosted.org/packages/85/0b/1732085540b01f65e4e7999e15864fe14cd18b12a95731a43fd6fd11b26a/webob-1.8.9.tar.gz", upload-time = 2024-10-24T03:19:20Z, size = 279775, hashes = { sha256 = "ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589" } } wheels = [{ url = "https://files.pythonhosted.org/packages/50/bd/c336448be43d40be28e71f2e0f3caf7ccb28e2755c58f4c02c065bfe3e8e/WebOb-1.8.9-py2.py3-none-any.whl", upload-time = 2024-10-24T03:19:18Z, size = 115364, hashes = { sha256 = "45e34c58ed0c7e2ecd238ffd34432487ff13d9ad459ddfd77895e67abba7c1f9" } }] - -[[packages]] -name = "wheel" -version = "0.21.0" -sdist = { url = "https://files.pythonhosted.org/packages/2a/a3/7a0a7d058bcaf0b08a50e7d55d5426522374ee466316489cc853d0d26dc1/wheel-0.21.0.tar.gz", upload-time = 2013-07-20T16:21:20Z, size = 39310, hashes = { sha256 = "68ad3e66560e9df1f1f435b480183ba24ef913da26556af9e5ae16cd94dd26e1" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/0d/e6/60c6e03ae967d076e72ec5298cd136d658e4b8eb6773d6aef3ff1abc8d04/wheel-0.21.0-py2.py3-none-any.whl", upload-time = 2013-07-20T16:21:03Z, size = 54184, hashes = { sha256 = "b0798123cd67a763637991812ab866f609a81516228273e89270d4bc91b276cd" } }] From e870a27ad6fb8f5c6d3f778897af3ca31699a9ae Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 21:23:22 -0400 Subject: [PATCH 23/31] FIX: Cleaner --- doc/sphinxext/mne_doc_utils.py | 20 +++++++++----------- mne/conftest.py | 7 +++---- mne/utils/misc.py | 5 +++-- mne/viz/tests/test_raw.py | 4 ++-- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/doc/sphinxext/mne_doc_utils.py b/doc/sphinxext/mne_doc_utils.py index 89941fec0fc..6c3198384b4 100644 --- a/doc/sphinxext/mne_doc_utils.py +++ b/doc/sphinxext/mne_doc_utils.py @@ -15,14 +15,11 @@ 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 _get_extra_data_path, sizeof_fmt from mne.viz import Brain sphinx_logger = sphinx.util.logging.getLogger("mne") @@ -205,19 +202,20 @@ def reset_modules(gallery_conf, fname, when): # to just test MNEQtBrowser skips = os.getenv("MNE_SKIP_INSTANCE_ASSERTIONS", "").lower() prefix = "" + request = object() # just give it something to say "we have done GC already" 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, request) if Plotter is not None and "plotter" not in skips: - _assert_no_instances(Plotter, when) + assert_no_instances(Plotter, when, request) if BackgroundPlotter is not None and "backgroundplotter" not in skips: - _assert_no_instances(BackgroundPlotter, when) + assert_no_instances(BackgroundPlotter, when, request) if vtkPolyData is not None and "vtkpolydata" not in skips: - _assert_no_instances(vtkPolyData, when) + assert_no_instances(vtkPolyData, when, request) if "_renderer" not in skips: - _assert_no_instances(_Renderer, when) + assert_no_instances(_Renderer, when, 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 @@ -226,7 +224,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, request) # This will overwrite some Sphinx printing but it's useful # for memory timestamps if os.getenv("SG_STAMP_STARTS", "").lower() == "true": diff --git a/mne/conftest.py b/mne/conftest.py index f126ad21f14..b42f4123ae1 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -22,7 +22,7 @@ import pytest from packaging.version import Version from pytest import StashKey, register_assert_rewrite -from refleak.testing import gc_collect_once +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") @@ -39,7 +39,6 @@ from mne.stats import cluster_level from mne.utils import ( Bunch, - _assert_no_instances, _check_qt_version, _chmod_rw_R, _pl, @@ -688,7 +687,7 @@ def pg_backend(request, garbage_collect): mne_qt_browser._browser_instances.clear() if not _test_passed(request): return - _assert_no_instances( + assert_no_instances( MNEQtBrowser, f"Closure of {request.node.name}", request=request ) @@ -1088,7 +1087,7 @@ def brain_gc(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)) + 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() diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 52ad240e7e6..2e82b4c98ad 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -352,11 +352,12 @@ def _file_like(obj): return all(callable(getattr(obj, name, None)) for name in ("read", "seek")) -def _assert_no_instances(cls, when="", *, request=None, objs=None): +# Low-effort backward compat wrapper in case other MNE libraries use this function +def _assert_no_instances(cls, when=""): __tracebackhide__ = True from refleak.testing import assert_no_instances - return assert_no_instances(cls, when=when, request=request, objs=objs) + return assert_no_instances(cls, when=when) def _resource_path(submodule, filename): diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index dab3deb5165..b4adce79f47 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -13,6 +13,7 @@ import pytest from matplotlib import backend_bases, rc_context from numpy.testing import assert_allclose, assert_array_equal +from refleak.testing import assert_no_instances import mne from mne import Annotations, create_info, pick_types @@ -21,7 +22,6 @@ from mne.datasets import testing from mne.io import RawArray from mne.utils import ( - _assert_no_instances, _dt_to_stamp, _record_warnings, catch_logging, @@ -1357,7 +1357,7 @@ def test_plotting_memory_garbage_collection(raw, pg_backend): from mne_qt_browser._pg_figure import MNEQtBrowser assert len(mne_qt_browser._browser_instances) == 0 - _assert_no_instances(MNEQtBrowser, "after closing") + assert_no_instances(MNEQtBrowser, "after closing") def test_plotting_scalebars(browser_backend, qtbot): From 2b44ae2bd2bb3c651cd4b71c8174c050e15a4765 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 21:23:39 -0400 Subject: [PATCH 24/31] TST: More [circle full] From 4c0869516594f3125cd94b95607ca4d649a4cbe1 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 21:39:00 -0400 Subject: [PATCH 25/31] FIX: use it --- mne/utils/misc.py | 5 +++-- mne/utils/tests/test_misc.py | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mne/utils/misc.py b/mne/utils/misc.py index 2e82b4c98ad..bf8478094b0 100644 --- a/mne/utils/misc.py +++ b/mne/utils/misc.py @@ -354,10 +354,11 @@ def _file_like(obj): # Low-effort backward compat wrapper in case other MNE libraries use this function def _assert_no_instances(cls, when=""): - __tracebackhide__ = True from refleak.testing import assert_no_instances - return assert_no_instances(cls, when=when) + __tracebackhide__ = True + + assert_no_instances(cls, when=when) def _resource_path(submodule, filename): diff --git a/mne/utils/tests/test_misc.py b/mne/utils/tests/test_misc.py index c3bc63dea76..64f00512664 100644 --- a/mne/utils/tests/test_misc.py +++ b/mne/utils/tests/test_misc.py @@ -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(): From 8f7a4767f4fe3e49935457d45da6ed4fe9ac9506 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 21:39:20 -0400 Subject: [PATCH 26/31] TST [circle full] From cf7eed7995ad81f1d196a08cb079f0dc48508c78 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 22:08:30 -0400 Subject: [PATCH 27/31] FIX: More [circle full] --- doc/sphinxext/mne_doc_utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/sphinxext/mne_doc_utils.py b/doc/sphinxext/mne_doc_utils.py index 6c3198384b4..f206af5e639 100644 --- a/doc/sphinxext/mne_doc_utils.py +++ b/doc/sphinxext/mne_doc_utils.py @@ -207,15 +207,15 @@ def reset_modules(gallery_conf, fname, when): prefix = "Clean " skips = skips.split(",") if "brain" not in skips: - assert_no_instances(Brain, when, request) + assert_no_instances(Brain, when=when, request=request) if Plotter is not None and "plotter" not in skips: - assert_no_instances(Plotter, when, request) + assert_no_instances(Plotter, when=when, request=request) if BackgroundPlotter is not None and "backgroundplotter" not in skips: - assert_no_instances(BackgroundPlotter, when, request) + assert_no_instances(BackgroundPlotter, when=when, request=request) if vtkPolyData is not None and "vtkpolydata" not in skips: - assert_no_instances(vtkPolyData, when, request) + assert_no_instances(vtkPolyData, when=when, request=request) if "_renderer" not in skips: - assert_no_instances(_Renderer, when, request) + 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 @@ -224,7 +224,7 @@ def reset_modules(gallery_conf, fname, when): if inst is not None: for _ in range(2): inst.processEvents() - assert_no_instances(MNEQtBrowser, when, request) + 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": From efdd6d6108adf7e98684cc530385a4d988562d48 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 3 Jul 2026 22:34:39 -0400 Subject: [PATCH 28/31] FIX: Fine [circle full] --- doc/sphinxext/mne_doc_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/sphinxext/mne_doc_utils.py b/doc/sphinxext/mne_doc_utils.py index f206af5e639..9575ef72cf1 100644 --- a/doc/sphinxext/mne_doc_utils.py +++ b/doc/sphinxext/mne_doc_utils.py @@ -19,7 +19,7 @@ from sphinx.errors import ExtensionError import mne -from mne.utils import _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") @@ -202,7 +202,8 @@ def reset_modules(gallery_conf, fname, when): # to just test MNEQtBrowser skips = os.getenv("MNE_SKIP_INSTANCE_ASSERTIONS", "").lower() prefix = "" - request = object() # just give it something to say "we have done GC already" + 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(",") From 1e47d5f446ce3f845807479f1260829a6ea9c861 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sat, 4 Jul 2026 07:44:50 -0400 Subject: [PATCH 29/31] Remove unused import in background_filtering.py --- tutorials/preprocessing/25_background_filtering.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tutorials/preprocessing/25_background_filtering.py b/tutorials/preprocessing/25_background_filtering.py index 537da6a22b2..e50db39dbd2 100644 --- a/tutorials/preprocessing/25_background_filtering.py +++ b/tutorials/preprocessing/25_background_filtering.py @@ -150,7 +150,6 @@ from scipy import signal import mne -from mne.fixes import minimum_phase from mne.time_frequency.tfr import morlet from mne.viz import plot_filter, plot_ideal_filter From 0e38f392bf95aa264c443bc8b2228d8b3f92affe Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sat, 4 Jul 2026 07:50:25 -0400 Subject: [PATCH 30/31] Update minimum-phase filter implementation --- tutorials/preprocessing/25_background_filtering.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tutorials/preprocessing/25_background_filtering.py b/tutorials/preprocessing/25_background_filtering.py index e50db39dbd2..bbb00ad18cc 100644 --- a/tutorials/preprocessing/25_background_filtering.py +++ b/tutorials/preprocessing/25_background_filtering.py @@ -322,11 +322,9 @@ # # We can construct a minimum-phase filter from our existing linear-phase # filter, and note that the falloff is not as steep. Here we do this with function -# ``mne.fixes.minimum_phase()`` to avoid a SciPy bug; once SciPy 1.14.0 is released you -# could directly use # :func:`scipy.signal.minimum_phase(..., half=False) `. -h_min = minimum_phase(h, half=False) +h_min = signal.minimum_phase(h, half=False) plot_filter(h_min, sfreq, freq, gain, "Minimum-phase", **kwargs) # %% From d598d42206af9fa0c91f77d1cc035c967e179b7c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 5 Jul 2026 15:25:52 -0400 Subject: [PATCH 31/31] FIX: Simplification --- mne/viz/_3d.py | 257 ++++++++++++++-------------------- mne/viz/backends/_abstract.py | 6 + mne/viz/backends/_pyvista.py | 85 ++++++++--- 3 files changed, 178 insertions(+), 170 deletions(-) diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 65441eba750..6b153b1d341 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -51,6 +51,7 @@ _angle_between_quats, _ensure_trans, _find_trans, + _find_vector_rotation, _frame_to_str, _get_trans, _get_transforms_to_coord_frame, @@ -1342,11 +1343,11 @@ def _plot_hpi_coils( ] ) hpi_loc = apply_trans(to_cf_t["head"], hpi_loc) - actor, _ = _plot_glyphs( + return _plot_glyphs( renderer=renderer, loc=hpi_loc, - color=defaults["hpi_color"], - scale=scale, + colors=defaults["hpi_color"], + scales=scale, opacity=opacity, orient_glyphs=orient_glyphs, scale_by_distance=scale_by_distance, @@ -1355,7 +1356,6 @@ def _plot_hpi_coils( check_inside=check_inside, nearest=nearest, ) - return actor def _get_nearest(nearest, check_inside, project_to_trans, proj_rr): @@ -1394,67 +1394,80 @@ def _orient_glyphs( def _plot_glyphs( renderer, loc, - color, - scale, - opacity=1, - mode="cylinder", + colors, + scales, + opacity, + *, orient_glyphs=False, scale_by_distance=False, project_points=False, mark_inside=False, surf=None, + orient_nn=None, + cylinder_geom=None, backface_culling=False, check_inside=None, nearest=None, ): - from matplotlib.colors import ListedColormap, to_rgba + """Plot glyphs (sensors, HPI coils, head-shape points) as one instanced actor. + + ``loc`` is already in render units. ``colors`` (any Matplotlib color spec or + an ``(n, 4)`` / ``(1, 4)`` RGBA array) and ``scales`` may be per-instance or + a single value to broadcast; a single GPU-instanced actor is produced + regardless. When ``surf`` is set (coreg), glyphs are oriented along, scaled + by distance to, projected onto, and/or marked inside the surface. Callers + that have already projected/oriented the points (e.g. projected EEG) can + instead pass per-instance orientation vectors as ``orient_nn`` and, if the + default coreg cylinder is not wanted, a ``cylinder_geom`` dict of + ``_cylinder_geom`` keyword arguments. + """ + from matplotlib.colors import to_rgba, to_rgba_array - _validate_type(mark_inside, bool, "mark_inside") - if surf is not None and len(loc) > 0: - defaults = DEFAULTS["coreg"] - scalars, vectors, proj_pts = _orient_glyphs( + defaults = DEFAULTS["coreg"] + n = len(loc) + if n == 0: + return None + colors = np.array(np.broadcast_to(to_rgba_array(colors), (n, 4)), float) + colors[:, 3] *= opacity + scales = np.broadcast_to(np.asarray(scales, float).reshape(-1), (n,)) + positions = loc + vectors = orient_nn # explicit per-instance orientation, if given + if surf is not None: + scalars, surf_vectors, proj_pts = _orient_glyphs( loc, surf, project_points, mark_inside, check_inside, nearest ) - if mark_inside: - colormap = ListedColormap([to_rgba("darkslategray"), to_rgba(color)]) - color = None - clim = [0, 1] - else: - scalars = None - colormap = None - clim = None - mode = "cylinder" if orient_glyphs else "sphere" - scale_mode = "vector" if scale_by_distance else "none" - x, y, z = proj_pts.T if project_points else loc.T - u, v, w = vectors.T - return renderer.quiver3d( - x, - y, - z, - u, - v, - w, - color=color, - scale=scale, - mode=mode, - glyph_height=defaults["eegp_height"], - glyph_center=(0.0, -defaults["eegp_height"], 0), - glyph_resolution=16, - glyph_radius=None, - opacity=opacity, - scale_mode=scale_mode, - scalars=scalars, - colormap=colormap, - clim=clim, - ) - else: - return renderer.sphere( - center=loc, - color=color, - scale=scale, - opacity=opacity, - backface_culling=backface_culling, + if project_points: + positions = proj_pts + if scale_by_distance: + scales = scales * np.linalg.norm(surf_vectors, axis=1) + if mark_inside: # recolor points that fall inside the surface + colors[scalars < 0.5, :3] = to_rgba("darkslategray")[:3] + if orient_glyphs: # point cylinders along the surface normal + vectors = surf_vectors + kind, template_kw = "sphere", dict() + quats = np.zeros((n, 3)) # identity: unrotated (sphere is orientation-free) + if vectors is not None: # orient cylinders along the given direction + kind = "cylinder" + template_kw = cylinder_geom or dict( + height=defaults["eegp_height"], + center=(0.0, -defaults["eegp_height"], 0.0), + resolution=16, ) + x_axis = np.array([1.0, 0.0, 0.0]) + nn = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) + rots = np.array([_find_vector_rotation(x_axis, this_nn) for this_nn in nn]) + quats = rot_to_quat(rots) + rr, tris = renderer._glyph_template(kind, **template_kw) + actor, _ = renderer.instanced_mesh( + rr=rr, + tris=tris, + positions=positions, + quats=quats, + colors=colors, + scales=scales, + backface_culling=backface_culling, + ) + return actor @verbose @@ -1485,11 +1498,11 @@ def _plot_head_shape_points( ) ext_loc = apply_trans(to_cf_t["head"], ext_loc) ext_loc = ext_loc[mask] if mask is not None else ext_loc - actor, _ = _plot_glyphs( + return _plot_glyphs( renderer=renderer, loc=ext_loc, - color=defaults["extra_color"], - scale=defaults["extra_scale"], + colors=defaults["extra_color"], + scales=defaults["extra_scale"], opacity=opacity, orient_glyphs=orient_glyphs, scale_by_distance=scale_by_distance, @@ -1499,7 +1512,6 @@ def _plot_head_shape_points( check_inside=check_inside, nearest=nearest, ) - return actor def _plot_forward(renderer, fwd, fwd_trans, fwd_scale=1, scale=1.5e-3, alpha=1): @@ -1693,104 +1705,51 @@ def _plot_sensors_3d( ) actors[ch_type].append(actor) else: + # One GPU-instanced actor regardless of how many distinct + # colors/scales are requested (broadcasting handles 1-vs-N). sens_loc = np.array(sens_loc, float) mask = ~np.isnan(sens_loc).any(axis=1) - if ch_type == "eegp": # special case, need to project + loc = sens_loc[mask] + these_colors = colors[mask] if len(colors) == len(mask) else colors + these_scales = scales[mask] if len(scales) == len(mask) else scales + orient_nn = cylinder_geom = None + backface_culling = False + actor_key = ch_type + if ch_type == "eegp": + # Projected EEG differs: reproject onto the scalp, orient along + # its normals, and use a single color/scale + flat disc glyph. logger.info("Projecting sensors to the head surface") - eegp_loc, eegp_nn = _project_onto_surface( - sens_loc[mask], head_surf, project_rrs=True, return_nn=True + loc, orient_nn = _project_onto_surface( + loc, head_surf, project_rrs=True, return_nn=True )[2:4] - eegp_loc *= unit_scalar - actor, _ = renderer.quiver3d( - x=eegp_loc[:, 0], - y=eegp_loc[:, 1], - z=eegp_loc[:, 2], - u=eegp_nn[:, 0], - v=eegp_nn[:, 1], - w=eegp_nn[:, 2], - color=colors[0], # TODO: Maybe eventually support multiple - mode="cylinder", - scale=scales[0] * unit_scalar, # TODO: Also someday maybe multiple - opacity=sensor_alpha[ch_type], - glyph_height=defaults["eegp_height"], - glyph_center=(0.0, -defaults["eegp_height"] / 2.0, 0), - glyph_resolution=20, - backface_culling=True, + these_colors = colors[0] # TODO: someday maybe support multiple + these_scales = scales[0] * unit_scalar # TODO: someday maybe multiple + height = defaults["eegp_height"] + cylinder_geom = dict( + radius=0.15, + height=height, + center=(0.0, -height / 2.0, 0.0), + resolution=20, ) - actors["eeg"].append(actor) - elif len(colors) == 1 and len(scales) == 1: - # Single color mode (one actor) - actor, _ = _plot_glyphs( - renderer=renderer, - loc=sens_loc[mask] * unit_scalar, - color=colors[0, :3], - scale=scales[0], - opacity=this_alpha * colors[0, 3], - orient_glyphs=orient_glyphs, - scale_by_distance=scale_by_distance, - project_points=project_points, - surf=surf, - check_inside=check_inside, - nearest=nearest, - ) - actors[ch_type].append(actor) - elif len(colors) == len(sens_loc) and len(scales) == 1: - # Multi-color single scale mode (multiple actors) - for loc, color, usable in zip(sens_loc, colors, mask): - if not usable: - continue - actor, _ = _plot_glyphs( - renderer=renderer, - loc=loc * unit_scalar, - color=color[:3], - scale=scales[0], - opacity=this_alpha * color[3], - orient_glyphs=orient_glyphs, - scale_by_distance=scale_by_distance, - project_points=project_points, - surf=surf, - check_inside=check_inside, - nearest=nearest, - ) - actors[ch_type].append(actor) - elif len(colors) == 1 and len(scales) == len(sens_loc): - # Multi-scale single color mode (multiple actors) - for loc, scale, usable in zip(sens_loc, scales, mask): - if not usable: - continue - actor, _ = _plot_glyphs( - renderer=renderer, - loc=loc * unit_scalar, - color=colors[0, :3], - scale=scale, - opacity=this_alpha * colors[0, 3], - orient_glyphs=orient_glyphs, - scale_by_distance=scale_by_distance, - project_points=project_points, - surf=surf, - check_inside=check_inside, - nearest=nearest, - ) - actors[ch_type].append(actor) - else: - # Multi-color multi-scale mode (multiple actors) - for loc, color, scale, usable in zip(sens_loc, colors, scales, mask): - if not usable: - continue - actor, _ = _plot_glyphs( - renderer=renderer, - loc=loc * unit_scalar, - color=color[:3], - scale=scale, - opacity=this_alpha * color[3], - orient_glyphs=orient_glyphs, - scale_by_distance=scale_by_distance, - project_points=project_points, - surf=surf, - check_inside=check_inside, - nearest=nearest, - ) - actors[ch_type].append(actor) + backface_culling = True + actor_key = "eeg" + actor = _plot_glyphs( + renderer=renderer, + loc=loc * unit_scalar, + colors=these_colors, + scales=these_scales, + opacity=this_alpha, + orient_glyphs=orient_glyphs, + scale_by_distance=scale_by_distance, + project_points=project_points, + surf=surf, + orient_nn=orient_nn, + cylinder_geom=cylinder_geom, + backface_culling=backface_culling, + check_inside=check_inside, + nearest=nearest, + ) + actors[actor_key].append(actor) actors = dict(actors) # get rid of defaultdict diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 5f5b78f37d3..9a4d764eb7e 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -480,6 +480,7 @@ def instanced_mesh( positions, quats, colors, + scales=None, opacity=1.0, backface_culling=False, name=None, @@ -511,6 +512,11 @@ def instanced_mesh( The per-instance RGBA color (float values between 0 and 1) to use for each instance. Per-instance alpha (the fourth column) is respected. + scales : array, shape (n_instances,) | None + The per-instance isotropic scale factor applied to the template + geometry. If ``None`` (the default), no per-instance scaling is + applied and the size baked into ``rr`` is used as-is (e.g. for + MEG coils). opacity : float A uniform opacity multiplier applied on top of the per-instance alpha values in ``colors``. diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 544de54a56b..d020f468e5a 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -639,26 +639,12 @@ def quiver3d( if mode == "cone": geom = pyvista.Cone(center=(0.5, 0, 0), radius=glyph_radius) elif mode == "cylinder": - # vtkCylinderSource's axis is along y, so rotate 90 degrees - # about z to match the arrow/cone convention of pointing - # along x - source = vtkCylinderSource() - if glyph_radius is not None: - source.SetRadius(glyph_radius) - if glyph_height is not None: - source.SetHeight(glyph_height) - if glyph_center is not None: - source.SetCenter(glyph_center) - if glyph_resolution is not None: - source.SetResolution(glyph_resolution) - source.Update() - tr = vtkTransform() - tr.RotateWXYZ(90, 0, 0, 1) - trp = vtkTransformFilter() - trp.SetInputData(source.GetOutput()) - trp.SetTransform(tr) - trp.Update() - geom = trp.GetOutput() + geom = _cylinder_geom( + radius=glyph_radius, + height=glyph_height, + center=glyph_center, + resolution=glyph_resolution, + ) elif mode == "oct": geom = pyvista.PlatonicSolid(kind="octahedron") if solid_transform is not None: @@ -688,6 +674,11 @@ def quiver3d( ) return actor, mesh + # quiver3d (above) and instanced_mesh (below) split along principled lines: + # quiver3d bakes a static, merged glyph mesh with direction-vector + # orientation and scalar/colormap coloring (arrows and friends), while + # instanced_mesh GPU-instances one template with per-instance, updatable + # quaternion orientation and RGBA coloring (sensors, MEG coils). def instanced_mesh( self, rr, @@ -695,6 +686,7 @@ def instanced_mesh( positions, quats, colors, + scales=None, opacity=1.0, backface_culling=False, *, @@ -713,7 +705,15 @@ def instanced_mesh( mapper.SetSourceData(geom) mapper.SetOrientationArray("orientation") mapper.SetOrientationModeToQuaternion() - mapper.ScalingOff() # coil size is baked into rr; no per-instance scale + if scales is None: + # size is baked into rr (e.g. MEG coils); no per-instance scaling + mapper.ScalingOff() + else: + cloud.point_data["scale"] = np.asarray(scales, float) + mapper.SetScaleArray("scale") + mapper.SetScaleModeToScaleByMagnitude() + mapper.SetScaleFactor(1.0) + mapper.ScalingOn() mapper.SetScalarModeToUsePointFieldData() mapper.SelectColorArray("colors") mapper.SetColorModeToDirectScalars() @@ -729,6 +729,23 @@ def instanced_mesh( ) return actor, cloud + def _glyph_template(self, kind, **kwargs): + """Return (rr, tris) for a standard template mesh for instanced_mesh. + + ``kind`` is ``"sphere"`` (unit-diameter, i.e. radius 0.5) or + ``"cylinder"`` (see ``_cylinder_geom`` for ``**kwargs``). The template + is oriented along +x so per-instance quaternions can point it anywhere. + """ + if kind == "sphere": + geom = pyvista.Sphere(radius=0.5, theta_resolution=8, phi_resolution=8) + else: + assert kind == "cylinder", kind + geom = pyvista.wrap(_cylinder_geom(**kwargs)) + geom = geom.triangulate() + rr = np.asarray(geom.points, float) + tris = np.asarray(geom.faces).reshape(-1, 4)[:, 1:] + return rr, tris + def text2d( self, x_window, @@ -1274,6 +1291,32 @@ def _add_camera_callback(camera, callback): return camera.AddObserver(vtkCommand.ModifiedEvent, callback) +def _cylinder_geom(radius=None, height=None, center=None, resolution=None): + """Build a cylinder vtkPolyData with its axis along +x. + + vtkCylinderSource's axis is along y, so we rotate 90 degrees about z to + match the arrow/cone convention of pointing along x (the axis that + vtkGlyph3D/vtkGlyph3DMapper orient toward the per-instance vector). + """ + source = vtkCylinderSource() + if radius is not None: + source.SetRadius(radius) + if height is not None: + source.SetHeight(height) + if center is not None: + source.SetCenter(center) + if resolution is not None: + source.SetResolution(resolution) + source.Update() + tr = vtkTransform() + tr.RotateWXYZ(90, 0, 0, 1) + trp = vtkTransformFilter() + trp.SetInputData(source.GetOutput()) + trp.SetTransform(tr) + trp.Update() + return trp.GetOutput() + + def _arrow_glyph(grid, factor): glyph = vtkGlyphSource2D() glyph.SetGlyphTypeToArrow()