Skip to content

Commit 9b93f03

Browse files
larsonerautofix-ci[bot]drammockpre-commit-ci[bot]
authored
ENH: Add upsampling for MEG helmet surface (#13179)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel McCloy <dan@mccloy.info> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent e65a559 commit 9b93f03

File tree

13 files changed

+124
-46
lines changed

13 files changed

+124
-46
lines changed

doc/changes/devel/13179.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug with :func:`mne.viz.plot_alignment` where ``eeg="projected"`` was not plotted, by `Eric Larson`_.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ``upsampling`` option to :func:`mne.make_field_map` to allow upsampling MEG helmet surfaces for plotting, by `Eric Larson`_.

examples/visualization/eeg_on_scalp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Plotting EEG sensors on the scalp
66
=================================
77
8-
In this example, digitized EEG sensor locations are shown on the scalp.
8+
In this example, digitized EEG sensor locations are shown on the scalp surface.
99
"""
1010
# Author: Eric Larson <larson.eric.d@gmail.com>
1111
#

examples/visualization/mne_helmet.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@
3535
ch_type="meg",
3636
subject="sample",
3737
subjects_dir=subjects_dir,
38+
upsampling=2,
3839
)
3940
time = 0.083
40-
fig = mne.viz.create_3d_figure((256, 256))
41+
fig = mne.viz.create_3d_figure((512, 512), bgcolor="w", title="MNE helmet")
4142
mne.viz.plot_alignment(
4243
evoked.info,
4344
subject="sample",
@@ -50,13 +51,18 @@
5051
coord_frame="mri",
5152
)
5253
evoked.plot_field(
53-
maps, time=time, fig=fig, time_label=None, vmax=5e-13, time_viewer=False
54+
maps,
55+
time=time,
56+
fig=fig,
57+
time_label=None,
58+
vmax=5e-13,
59+
time_viewer=False,
5460
)
5561
mne.viz.set_3d_view(
5662
fig,
5763
azimuth=40,
5864
elevation=87,
5965
focalpoint=(0.0, -0.01, 0.04),
60-
roll=-25,
66+
roll=-105,
6167
distance=0.55,
6268
)

mne/forward/_field_interpolation.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ def make_field_map(
446446
origin=(0.0, 0.0, 0.04),
447447
n_jobs=None,
448448
*,
449+
upsampling=1,
449450
head_source=("bem", "head"),
450451
verbose=None,
451452
):
@@ -483,6 +484,9 @@ def make_field_map(
483484
484485
.. versionadded:: 0.11
485486
%(n_jobs)s
487+
%(helmet_upsampling)s
488+
489+
.. versionadded:: 1.10
486490
%(head_source)s
487491
488492
.. versionadded:: 1.1
@@ -527,7 +531,7 @@ def make_field_map(
527531
surfs = []
528532
for this_type in types:
529533
if this_type == "meg" and meg_surf == "helmet":
530-
surf = get_meg_helmet_surf(info, trans)
534+
surf = get_meg_helmet_surf(info, trans, upsampling=upsampling)
531535
else:
532536
surf = get_head_surf(subject, source=head_source, subjects_dir=subjects_dir)
533537
surfs.append(surf)

mne/surface.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
_hashable_ndarray,
4545
_import_nibabel,
4646
_pl,
47+
_soft_import,
4748
_TempDir,
4849
_validate_type,
4950
fill_doc,
@@ -173,7 +174,7 @@ def _get_head_surface(subject, source, subjects_dir, on_defects, raise_error=Tru
173174

174175

175176
@verbose
176-
def get_meg_helmet_surf(info, trans=None, *, verbose=None):
177+
def get_meg_helmet_surf(info, trans=None, *, upsampling=1, verbose=None):
177178
"""Load the MEG helmet associated with the MEG sensors.
178179
179180
Parameters
@@ -183,6 +184,7 @@ def get_meg_helmet_surf(info, trans=None, *, verbose=None):
183184
The head<->MRI transformation, usually obtained using
184185
read_trans(). Can be None, in which case the surface will
185186
be in head coordinates instead of MRI coordinates.
187+
%(helmet_upsampling)s
186188
%(verbose)s
187189
188190
Returns
@@ -198,7 +200,10 @@ def get_meg_helmet_surf(info, trans=None, *, verbose=None):
198200
from .bem import _fit_sphere, read_bem_surfaces
199201
from .channels.channels import _get_meg_system
200202

203+
_validate_type(upsampling, "int", "upsampling")
204+
201205
system, have_helmet = _get_meg_system(info)
206+
incomplete = False
202207
if have_helmet:
203208
logger.info(f"Getting helmet for system {system}")
204209
fname = _helmet_path / f"{system}.fif.gz"
@@ -231,8 +236,24 @@ def get_meg_helmet_surf(info, trans=None, *, verbose=None):
231236
# remove the frontal point we added from the simplices
232237
tris = tris[(tris != len(sph) - 1).all(-1)]
233238
tris = _reorder_ccw(rr, tris)
234-
235239
surf = dict(rr=rr, tris=tris)
240+
incomplete = True
241+
if upsampling > 1:
242+
# Use VTK (could also use Butterfly but Loop is smoother)
243+
pv = _soft_import("pyvista", "upsample a mesh")
244+
factor = 4 ** (upsampling - 1)
245+
rr, tris = surf["rr"], surf["tris"]
246+
logger.info(
247+
f"Upsampling from {len(rr)} to {len(rr) * factor} vertices ({upsampling=})"
248+
)
249+
tris = np.c_[np.full(len(tris), 3), tris]
250+
mesh = pv.PolyData(rr, tris)
251+
mesh = mesh.subdivide(upsampling - 1, subfilter="linear")
252+
rr, tris = mesh.points, mesh.faces.reshape(-1, 4)[:, 1:]
253+
tris = _reorder_ccw(rr, tris)
254+
surf = dict(rr=rr, tris=tris)
255+
incomplete = True
256+
if incomplete:
236257
complete_surface_info(surf, copy=False, verbose=False)
237258

238259
# Ignore what the file says, it's in device coords and we want MRI coords

mne/utils/docs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,6 +2009,13 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
20092009
:func:`mne.get_head_surf` for more information.
20102010
"""
20112011

2012+
docdict["helmet_upsampling"] = """
2013+
upsampling : int
2014+
The upsampling factor to use for the helmet mesh. The default (1) does no
2015+
upsampling. Larger integers lead to more densely sampled helmet surfaces, and
2016+
the number of vertices increases as a factor of ``4**(upsampling-1)``.
2017+
"""
2018+
20122019
docdict["hitachi_fname"] = """
20132020
fname : list | str
20142021
Path(s) to the Hitachi CSV file(s). This should only be a list for

mne/viz/_3d.py

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ def plot_alignment(
915915
surf, coord_frame, [to_cf_t["mri"], to_cf_t["head"]], copy=True
916916
)
917917
renderer.surface(
918+
name=key,
918919
surface=surf,
919920
color=colors[key],
920921
opacity=alphas[key],
@@ -1021,7 +1022,7 @@ def _handle_sensor_types(meg, eeg, fnirs):
10211022

10221023
alpha_map = dict(
10231024
meg=dict(sensors="meg", helmet="meg_helmet", ref="ref_meg"),
1024-
eeg=dict(original="eeg", projected="eeg_projected"),
1025+
eeg=dict(original="eeg", projected="eegp"),
10251026
fnirs=dict(channels="fnirs", pairs="fnirs_pairs"),
10261027
)
10271028
sensor_alpha = {
@@ -1169,7 +1170,11 @@ def _plot_helmet(
11691170
src_surf, coord_frame, [to_cf_t["mri"], to_cf_t["head"]], copy=True
11701171
)
11711172
actor, dst_surf = renderer.surface(
1172-
surface=src_surf, color=color, opacity=alpha, backface_culling=False
1173+
surface=src_surf,
1174+
color=color,
1175+
opacity=alpha,
1176+
backface_culling=False,
1177+
name="helmet",
11731178
)
11741179
return actor, dst_surf, src_surf
11751180

@@ -1538,7 +1543,7 @@ def _plot_sensors_3d(
15381543
if not fnirs or "channels" not in fnirs:
15391544
plot_sensors = False
15401545
elif ch_type == "eeg":
1541-
if not eeg or "original" not in eeg:
1546+
if not eeg:
15421547
plot_sensors = False
15431548
elif ch_type == "meg":
15441549
if not meg or "sensors" not in meg:
@@ -1547,7 +1552,13 @@ def _plot_sensors_3d(
15471552
if isinstance(ch_coord, tuple): # is meg, plot coil
15481553
ch_coord = dict(rr=ch_coord[0] * unit_scalar, tris=ch_coord[1])
15491554
if plot_sensors:
1550-
locs[ch_type].append(ch_coord)
1555+
if ch_type == "eeg":
1556+
if "original" in eeg:
1557+
locs[ch_type].append(ch_coord)
1558+
if "projected" in eeg:
1559+
locs["eegp"].append(ch_coord)
1560+
else:
1561+
locs[ch_type].append(ch_coord)
15511562
if ch_name in sources and "sources" in fnirs:
15521563
locs["source"].append(sources[ch_name])
15531564
if ch_name in detectors and "detectors" in fnirs:
@@ -1628,7 +1639,30 @@ def _plot_sensors_3d(
16281639
else:
16291640
sens_loc = np.array(sens_loc, float)
16301641
mask = ~np.isnan(sens_loc).any(axis=1)
1631-
if len(colors) == 1 and len(scales) == 1:
1642+
if ch_type == "eegp": # special case, need to project
1643+
logger.info("Projecting sensors to the head surface")
1644+
eegp_loc, eegp_nn = _project_onto_surface(
1645+
sens_loc[mask], head_surf, project_rrs=True, return_nn=True
1646+
)[2:4]
1647+
eegp_loc *= unit_scalar
1648+
actor, _ = renderer.quiver3d(
1649+
x=eegp_loc[:, 0],
1650+
y=eegp_loc[:, 1],
1651+
z=eegp_loc[:, 2],
1652+
u=eegp_nn[:, 0],
1653+
v=eegp_nn[:, 1],
1654+
w=eegp_nn[:, 2],
1655+
color=colors[0], # TODO: Maybe eventually support multiple
1656+
mode="cylinder",
1657+
scale=scales[0] * unit_scalar, # TODO: Also someday maybe multiple
1658+
opacity=sensor_alpha[ch_type],
1659+
glyph_height=defaults["eegp_height"],
1660+
glyph_center=(0.0, -defaults["eegp_height"] / 2.0, 0),
1661+
glyph_resolution=20,
1662+
backface_culling=True,
1663+
)
1664+
actors["eeg"].append(actor)
1665+
elif len(colors) == 1 and len(scales) == 1:
16321666
# Single color mode (one actor)
16331667
actor, _ = _plot_glyphs(
16341668
renderer=renderer,
@@ -1701,29 +1735,7 @@ def _plot_sensors_3d(
17011735
nearest=nearest,
17021736
)
17031737
actors[ch_type].append(actor)
1704-
if ch_type == "eeg" and "projected" in eeg:
1705-
logger.info("Projecting sensors to the head surface")
1706-
eegp_loc, eegp_nn = _project_onto_surface(
1707-
sens_loc[mask], head_surf, project_rrs=True, return_nn=True
1708-
)[2:4]
1709-
eegp_loc *= unit_scalar
1710-
actor, _ = renderer.quiver3d(
1711-
x=eegp_loc[:, 0],
1712-
y=eegp_loc[:, 1],
1713-
z=eegp_loc[:, 2],
1714-
u=eegp_nn[:, 0],
1715-
v=eegp_nn[:, 1],
1716-
w=eegp_nn[:, 2],
1717-
color=defaults["eegp_color"],
1718-
mode="cylinder",
1719-
scale=defaults["eegp_scale"] * unit_scalar,
1720-
opacity=sensor_alpha["eeg_projected"],
1721-
glyph_height=defaults["eegp_height"],
1722-
glyph_center=(0.0, -defaults["eegp_height"] / 2.0, 0),
1723-
glyph_resolution=20,
1724-
backface_culling=True,
1725-
)
1726-
actors["eeg"].append(actor)
1738+
17271739
actors = dict(actors) # get rid of defaultdict
17281740

17291741
return actors

mne/viz/backends/_abstract.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def mesh(
127127
triangles,
128128
color,
129129
opacity=1.0,
130+
*,
130131
backface_culling=False,
131132
scalars=None,
132133
colormap=None,
@@ -137,6 +138,7 @@ def mesh(
137138
line_width=1.0,
138139
normals=None,
139140
polygon_offset=None,
141+
name=None,
140142
**kwargs,
141143
):
142144
"""Add a mesh in the scene.
@@ -183,6 +185,8 @@ def mesh(
183185
The array containing the normal of each vertex.
184186
polygon_offset : float
185187
If not None, the factor used to resolve coincident topology.
188+
name : str | None
189+
The name of the mesh.
186190
kwargs : args
187191
The arguments to pass to triangular_mesh
188192
@@ -254,6 +258,8 @@ def surface(
254258
scalars=None,
255259
backface_culling=False,
256260
polygon_offset=None,
261+
*,
262+
name=None,
257263
):
258264
"""Add a surface in the scene.
259265
@@ -281,6 +287,8 @@ def surface(
281287
If True, enable backface culling on the surface.
282288
polygon_offset : float
283289
If not None, the factor used to resolve coincident topology.
290+
name : str | None
291+
Name of the surface.
284292
"""
285293
pass
286294

mne/viz/backends/_pyvista.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ def polydata(
356356
representation="surface",
357357
line_width=1.0,
358358
polygon_offset=None,
359+
*,
360+
name=None,
359361
**kwargs,
360362
):
361363
from matplotlib.colors import to_rgba_array
@@ -386,6 +388,7 @@ def polydata(
386388
actor = _add_mesh(
387389
plotter=self.plotter,
388390
mesh=mesh,
391+
name=name,
389392
color=color,
390393
scalars=scalars,
391394
edge_color=color,
@@ -430,6 +433,7 @@ def mesh(
430433
line_width=1.0,
431434
normals=None,
432435
polygon_offset=None,
436+
name=None,
433437
**kwargs,
434438
):
435439
vertices = np.c_[x, y, z].astype(float)
@@ -449,6 +453,7 @@ def mesh(
449453
representation=representation,
450454
line_width=line_width,
451455
polygon_offset=polygon_offset,
456+
name=name,
452457
**kwargs,
453458
)
454459

@@ -504,6 +509,8 @@ def surface(
504509
scalars=None,
505510
backface_culling=False,
506511
polygon_offset=None,
512+
*,
513+
name=None,
507514
):
508515
normals = surface.get("nn", None)
509516
vertices = np.array(surface["rr"])
@@ -524,6 +531,7 @@ def surface(
524531
vmin=vmin,
525532
vmax=vmax,
526533
polygon_offset=polygon_offset,
534+
name=name,
527535
)
528536

529537
def sphere(
@@ -1019,7 +1027,6 @@ def _silhouette(self, mesh, color=None, line_width=None, alpha=None, decimate=No
10191027
silhouette_mapper.SetInputConnection(silhouette_filter.GetOutputPort())
10201028
actor, prop = self.plotter.add_actor(
10211029
silhouette_mapper,
1022-
name=None,
10231030
culling=False,
10241031
pickable=False,
10251032
reset_camera=False,

0 commit comments

Comments
 (0)