Skip to content

Commit d2a7a69

Browse files
ENH: Add public LayeredMesh and brain.layered_meshes (#13970)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 7c0b74a commit d2a7a69

12 files changed

Lines changed: 213 additions & 51 deletions

File tree

doc/api/visualization.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Visualization
1414
:toctree: ../generated/
1515

1616
Brain
17+
LayeredMesh
1718
ClickableImage
1819
EvokedField
1920
Figure3D
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added public :class:`mne.viz.LayeredMesh` class (previously private ``_LayeredMesh``) with a ``smooth_mat`` attribute for efficient real-time source-space data streaming. Added ``Brain.layered_meshes`` attribute to access meshes after plotting, and a ``key`` parameter to :meth:`mne.viz.Brain.add_data` for named overlays, by :newcontrib:`Payam Sadeghi-Shabestari`.

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@
267267
.. _Paul Roujansky: https://github.com/paulroujansky
268268
.. _Pavel Navratil: https://github.com/navrpa13
269269
.. _Pavel Popov: https://github.com/paavalipopov
270+
.. _Payam Sadeghi-Shabestari: https://github.com/payamsash
270271
.. _Peter Molfese: https://github.com/pmolfese
271272
.. _Phillip Alday: https://palday.bitbucket.io
272273
.. _Pierre Ablin: https://pierreablin.com

doc/conf.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,8 @@
426426
"polars",
427427
"default",
428428
# unlinkable
429+
"_Renderer",
430+
"n_triangles",
429431
"CoregistrationUI",
430432
"mne_qt_browser.figure.MNEQtBrowser",
431433
# pooch, since its website is unreliable and users will rarely need the links

examples/visualization/brain.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,48 @@
136136
fig.suptitle("Dipole Fits Scaled by Amplitude and Colored by GOF")
137137

138138

139+
# %%
140+
# Update overlays via ``Brain.layered_meshes``
141+
# --------------------------------------------
142+
#
143+
# After :meth:`~mne.viz.Brain.add_data` is called, each hemisphere's surface
144+
# is backed by a :class:`~mne.viz.LayeredMesh` stored in
145+
# ``brain.layered_meshes``. Calling
146+
# :meth:`~mne.viz.LayeredMesh.update_overlay` pushes new scalar data without
147+
# rebuilding the full rendering pipeline — the key operation for packages that
148+
# stream source-space data onto the brain in real time, such as
149+
# `MNE-RT <https://payamsash.github.io/mne-rt/>`_.
150+
#
151+
# Here we add an initial bottom-to-top gradient as the first data frame.
152+
153+
brain = mne.viz.Brain("sample", subjects_dir=subjects_dir, hemi="lh", **brain_kwargs)
154+
coords = brain.geo["lh"].coords
155+
data_t0 = coords[:, 2]
156+
data_t0 = (data_t0 - data_t0.min()) / (data_t0.max() - data_t0.min())
157+
brain.add_data(
158+
data_t0, hemi="lh", fmin=0, fmax=1, colormap="viridis", smoothing_steps=5
159+
)
160+
brain.show_view(azimuth=190, elevation=70, distance=350, focalpoint=(0, 0, 20))
161+
162+
# %%
163+
# Simulate a new data frame arriving: call
164+
# :meth:`~mne.viz.LayeredMesh.update_overlay` on the **same** brain to replace
165+
# the scalars in-place — no new mesh, no new actor, no pipeline rebuild.
166+
167+
# we create a new brain here for comparison purposes
168+
brain_update = mne.viz.Brain(
169+
"sample", subjects_dir=subjects_dir, hemi="lh", **brain_kwargs
170+
)
171+
brain_update.add_data(
172+
data_t0, hemi="lh", fmin=0, fmax=1, colormap="viridis", smoothing_steps=5
173+
)
174+
brain_update.show_view(azimuth=190, elevation=70, distance=350, focalpoint=(0, 0, 20))
175+
data_t1 = coords[:, 1]
176+
data_t1 = (data_t1 - data_t1.min()) / (data_t1.max() - data_t1.min())
177+
mesh = brain_update.layered_meshes["lh"]
178+
mesh.update_overlay(name="data", scalars=data_t1)
179+
mesh.update()
180+
139181
# %%
140182
# Use per-vertex opacity for distributed data
141183
# --------------------------------------------

mne/viz/_3d_overlay.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,45 @@ def _norm(self, rng):
7474
return (self._scalars - rng[0]) / factor
7575

7676

77-
class _LayeredMesh:
77+
class LayeredMesh:
78+
"""A mesh with support for layered RGBA overlays and optional smoothing.
79+
80+
This class manages a single brain-surface mesh and composites multiple
81+
named overlays (e.g., curvature, data, labels) on top of each other using
82+
alpha blending. It is the object stored in
83+
``Brain.layered_meshes``.
84+
85+
.. warning::
86+
This class is not meant to be instantiated directly, and the
87+
initialization API could change at any time! Use
88+
:meth:`mne.viz.Brain.add_data` to create instances.
89+
90+
Parameters
91+
----------
92+
renderer : instance of _Renderer
93+
The renderer used to create the underlying mesh polydata.
94+
vertices : array, shape (n_vertices, 3)
95+
Vertex coordinates in metres.
96+
triangles : array, shape (n_triangles, 3)
97+
Triangle indices into ``vertices``.
98+
normals : array, shape (n_vertices, 3)
99+
Vertex normals.
100+
101+
Attributes
102+
----------
103+
smooth_mat : scipy.sparse.csr_array or None
104+
Optional spatial smoother / upsampler applied to scalar data before
105+
rendering. When set (e.g., by
106+
:meth:`~mne.viz.Brain.set_data_smoothing`), every call to
107+
:meth:`update_overlay` will multiply the incoming scalars by this
108+
matrix before storing them. Shape must be
109+
``(n_surface_vertices, n_source_vertices)``.
110+
111+
Notes
112+
-----
113+
.. versionadded:: 1.13
114+
"""
115+
78116
def __init__(self, renderer, vertices, triangles, normals):
79117
self._renderer = renderer
80118
self._vertices = vertices
@@ -91,8 +129,9 @@ def __init__(self, renderer, vertices, triangles, normals):
91129

92130
self._default_scalars = np.ones(vertices.shape)
93131
self._default_scalars_name = "Data"
132+
self.smooth_mat = None
94133

95-
def map(self):
134+
def _map(self):
96135
kwargs = {
97136
"color": None,
98137
"pickable": True,
@@ -135,7 +174,30 @@ def _compose_overlays(self):
135174
B = self._compute_over(cache, A)
136175
return B, cache
137176

138-
def add_overlay(self, scalars, colormap, rng, opacity, name):
177+
def add_overlay(self, scalars, colormap, rng, opacity, name, smooth=False):
178+
"""Add a named overlay to the mesh.
179+
180+
Parameters
181+
----------
182+
scalars : array, shape (n_vertices,)
183+
Scalar values to display. If ``smooth=True`` and
184+
``smooth_mat`` is set, shape must be ``(n_src_vertices,)``.
185+
colormap : array, shape (n_colors, 4)
186+
RGBA colormap table (values in ``[0, 255]``).
187+
rng : array-like, shape (2,)
188+
``[min, max]`` range for colormap mapping.
189+
opacity : float | None
190+
Overlay opacity in ``[0, 1]``. ``None`` keeps the existing value.
191+
name : str
192+
Unique key identifying this overlay.
193+
smooth : bool
194+
If ``True`` and ``smooth_mat`` is set, multiply ``scalars``
195+
by ``smooth_mat`` before rendering. Use ``True`` only for
196+
source-space data; surface-space overlays (curvature, labels,
197+
annotations) should use the default ``False``.
198+
"""
199+
if smooth and self.smooth_mat is not None:
200+
scalars = self.smooth_mat.dot(scalars)
139201
overlay = _Overlay(
140202
scalars=scalars,
141203
colormap=colormap,
@@ -156,6 +218,13 @@ def add_overlay(self, scalars, colormap, rng, opacity, name):
156218
self._apply()
157219

158220
def remove_overlay(self, names):
221+
"""Remove one or more overlays by name.
222+
223+
Parameters
224+
----------
225+
names : str | list of str
226+
Name(s) of the overlay(s) to remove.
227+
"""
159228
to_update = False
160229
if not isinstance(names, list):
161230
names = [names]
@@ -172,6 +241,14 @@ def _apply(self):
172241
self._polydata[self._default_scalars_name] = self._current_colors
173242

174243
def update(self, colors=None):
244+
"""Recompose all overlays and refresh the mesh texture.
245+
246+
Parameters
247+
----------
248+
colors : array-like of shape (n_triangles, 4) | None
249+
Pre-composited RGBA colors to blend over the cached layer stack.
250+
If ``None``, all overlays are recomposed from scratch.
251+
"""
175252
if colors is not None and self._cached_colors is not None:
176253
self._current_colors = self._compute_over(self._cached_colors, colors)
177254
else:
@@ -187,10 +264,30 @@ def _clean(self):
187264
self._renderer = None
188265

189266
def update_overlay(self, name, scalars=None, colormap=None, opacity=None, rng=None):
267+
"""Update an existing overlay in-place.
268+
269+
Parameters
270+
----------
271+
name : str
272+
Key of the overlay to update (must already exist).
273+
scalars : array, shape (n_vertices,) | None
274+
New scalar values. If ``None``, scalars are unchanged.
275+
If ``smooth_mat`` is set, smoothing is applied automatically
276+
(matching the behaviour of the original :meth:`~mne.viz.Brain.add_data`
277+
call that created the overlay).
278+
colormap : array, shape (n_colors, 4) | None
279+
New RGBA colormap table. If ``None``, colormap is unchanged.
280+
opacity : float | None
281+
New opacity in ``[0, 1]``. If ``None``, opacity is unchanged.
282+
rng : array-like, shape (2,) | None
283+
New ``[min, max]`` colormap range. If ``None``, range is unchanged.
284+
"""
190285
overlay = self._overlays.get(name, None)
191286
if overlay is None:
192287
return
193288
if scalars is not None:
289+
if self.smooth_mat is not None:
290+
scalars = self.smooth_mat.dot(scalars)
194291
overlay._scalars = scalars
195292
if colormap is not None:
196293
overlay._colormap = colormap

mne/viz/__init__.pyi

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
__all__ = [
22
"Brain",
3+
"LayeredMesh",
34
"ClickableImage",
45
"EvokedField",
56
"Figure3D",
@@ -102,7 +103,7 @@ from ._3d import (
102103
set_3d_options,
103104
snapshot_brain_montage,
104105
)
105-
from ._brain import Brain
106+
from ._brain import Brain, LayeredMesh
106107
from ._figure import get_browser_backend, set_browser_backend, use_browser_backend
107108
from ._proj import plot_projs_joint
108109
from .backends._abstract import Figure3D

mne/viz/_brain/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# License: BSD-3-Clause
55
# Copyright the MNE-Python contributors.
66

7-
from ._brain import Brain, _LayeredMesh
7+
from ._brain import Brain, LayeredMesh
88
from ._scraper import _BrainScraper
99
from ._linkviewer import _LinkViewer
1010

0 commit comments

Comments
 (0)