Skip to content

Commit 0de3a35

Browse files
ENH: support per-vertex opacity in 3D overlays (#13706)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent 219e8a1 commit 0de3a35

7 files changed

Lines changed: 119 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for scalar or per-vertex ``alpha`` (shape ``(n_vertices,)``) in distributed overlays, including validation/errors for invalid shapes, tests, and a visualization example in ``examples/visualization/brain.py`` using :meth:`mne.viz.Brain.add_data`, by :newcontrib:`Pragnya Khandelwal`. (:gh:`13706`)

doc/changes/names.inc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@
259259
.. _Pierre Guetschel: https://github.com/PierreGtch
260260
.. _Pierre-Antoine Bannier: https://github.com/PABannier
261261
.. _Ping-Keng Jao: https://github.com/nafraw
262+
.. _Pragnya Khandelwal: https://github.com/PragnyaKhandelwal
262263
.. _Proloy Das: https://github.com/proloyd
263264
.. _Qian Chu: https://github.com/qian-chu
264265
.. _Qianliang Li: https://www.dtu.dk/english/service/phonebook/person?id=126774

examples/visualization/brain.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,35 @@
134134
norm = Normalize(vmin=0, vmax=dip.gof.max())
135135
fig.colorbar(ScalarMappable(norm=norm, cmap=cmap), cax=cax)
136136
fig.suptitle("Dipole Fits Scaled by Amplitude and Colored by GOF")
137+
138+
139+
# %%
140+
# Use per-vertex opacity for distributed data
141+
# --------------------------------------------
142+
#
143+
# You can provide an array for ``alpha`` in :meth:`mne.viz.Brain.add_data`
144+
# to control transparency per vertex. This can be useful to emphasize a
145+
# subset of vertices while still showing surrounding context.
146+
147+
brain = mne.viz.Brain("sample", subjects_dir=subjects_dir, hemi="lh", **brain_kwargs)
148+
coords = brain.geo["lh"].coords
149+
n_vertices = len(coords)
150+
151+
# Build synthetic data: a smooth left-to-right gradient color-wise in the Y
152+
# (front-back) direction, plus a matching opacity ramp from mostly transparent to
153+
# fully opaque in the X (left-right) direction.
154+
data = coords[:, 1]
155+
data = (data - data.min()) / (data.max() - data.min())
156+
vertex_alpha = -coords[:, 0]
157+
vertex_alpha = (vertex_alpha - vertex_alpha.min()) / (
158+
vertex_alpha.max() - vertex_alpha.min()
159+
)
160+
161+
brain.add_data(
162+
data,
163+
hemi="lh",
164+
alpha=vertex_alpha,
165+
colormap="viridis",
166+
smoothing_steps=5,
167+
)
168+
brain.show_view(azimuth=190, elevation=70, distance=350, focalpoint=(0, 0, 20))

mne/viz/_3d_overlay.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,29 @@ def to_colors(self):
4343
scalars = self._norm(rng)
4444

4545
colors = cmap(scalars)
46-
if self._opacity is not None:
47-
colors[:, 3] *= self._opacity
46+
opacity = self._check_opacity(colors.shape[0])
47+
if opacity is not None:
48+
colors[:, 3] *= opacity
4849
return colors
4950

51+
def _check_opacity(self, n_vertices):
52+
if self._opacity is None:
53+
return None
54+
opacity = np.asarray(self._opacity)
55+
if opacity.ndim == 0:
56+
return float(opacity)
57+
if opacity.ndim != 1:
58+
raise ValueError(
59+
"opacity must be a scalar or a 1D array with one value per "
60+
f"vertex, got an array with shape {opacity.shape}"
61+
)
62+
if len(opacity) != n_vertices:
63+
raise ValueError(
64+
"opacity array must have one value per vertex, got "
65+
f"{len(opacity)} values for {n_vertices} vertices"
66+
)
67+
return opacity
68+
5069
def _norm(self, rng):
5170
if rng[0] == rng[1]:
5271
factor = 1 if rng[0] == 0 else 1e-6 * rng[0]

mne/viz/_brain/_brain.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,8 +1709,12 @@ def add_data(
17091709
between 0 and 255), the default "auto" chooses a default divergent
17101710
colormap, if "center" is given (currently "icefire"), otherwise a
17111711
default sequential colormap (currently "rocket").
1712-
alpha : float in [0, 1]
1713-
Alpha level to control opacity of the overlay.
1712+
alpha : float in [0, 1] | array, shape (n_vertices,)
1713+
Alpha level to control opacity of the overlay. A scalar applies
1714+
globally, while a 1D array applies per-vertex opacity.
1715+
1716+
.. versionchanged:: 1.12
1717+
Added support for per-vertex alpha.
17141718
vertices : numpy array
17151719
Vertices for which the data is defined (needed if
17161720
``len(data) < nvtx``).

mne/viz/_brain/tests/test_brain.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,28 @@ def test_layered_mesh(renderer_interactive_pyvistaqt):
162162
assert "test2" not in mesh._overlays
163163
mesh.update()
164164
assert len(mesh._overlays) == 1
165+
166+
mesh.remove_overlay("test1")
167+
mesh.update()
168+
169+
opacity = np.array([0.0, 0.5, 1.0, 0.25])
170+
mesh.add_overlay(
171+
scalars=np.array([0, 1, 1, 0]),
172+
colormap=np.array([(255, 255, 255, 255), (0, 0, 0, 255)]),
173+
rng=[0, 1],
174+
opacity=opacity,
175+
name="test3",
176+
)
177+
assert_allclose(mesh._current_colors[:, 3], opacity, atol=1.0 / 255.0)
178+
179+
with pytest.raises(ValueError, match="one value per vertex"):
180+
mesh.add_overlay(
181+
scalars=np.array([0, 1, 1, 0]),
182+
colormap=np.array([(255, 255, 255, 255), (0, 0, 0, 255)]),
183+
rng=[0, 1],
184+
opacity=np.array([0.1, 0.2, 0.3]),
185+
name="bad-opacity",
186+
)
165187
mesh._clean()
166188

167189

mne/viz/tests/test_3d_overlay.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Authors: The MNE-Python contributors.
2+
# License: BSD-3-Clause
3+
# Copyright the MNE-Python contributors.
4+
5+
import numpy as np
6+
import pytest
7+
from numpy.testing import assert_allclose
8+
9+
from mne.viz._3d_overlay import _Overlay
10+
11+
12+
def test_overlay_opacity_per_vertex():
13+
"""Test per-vertex opacity support in overlay color mapping."""
14+
n_vertices = 4
15+
overlay = _Overlay(
16+
scalars=np.linspace(0, 1, n_vertices),
17+
colormap="viridis",
18+
rng=(0.0, 1.0),
19+
opacity=np.array([0.0, 0.25, 0.5, 1.0]),
20+
name="test",
21+
)
22+
colors = overlay.to_colors()
23+
assert_allclose(colors[:, 3], [0.0, 0.25, 0.5, 1.0])
24+
25+
26+
def test_overlay_opacity_bad_shape():
27+
"""Test that invalid per-vertex opacity raises."""
28+
overlay = _Overlay(
29+
scalars=np.linspace(0, 1, 4),
30+
colormap="viridis",
31+
rng=(0.0, 1.0),
32+
opacity=np.array([0.1, 0.2, 0.3]),
33+
name="test",
34+
)
35+
with pytest.raises(ValueError, match="one value per vertex"):
36+
overlay.to_colors()

0 commit comments

Comments
 (0)