Skip to content

Commit d2883d5

Browse files
authored
Interactive version of plot_evoked_fieldmap (#11942)
1 parent e8baea6 commit d2883d5

File tree

18 files changed

+991
-265
lines changed

18 files changed

+991
-265
lines changed

doc/changes/devel.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Enhancements
3434
- Add inferring EEGLAB files' montage unit automatically based on estimated head radius using :func:`read_raw_eeglab(..., montage_units="auto") <mne.io.read_raw_eeglab>` (:gh:`11925` by `Jack Zhang`_, :gh:`11951` by `Eric Larson`_)
3535
- Add :class:`~mne.time_frequency.EpochsSpectrumArray` and :class:`~mne.time_frequency.SpectrumArray` to support creating power spectra from :class:`NumPy array <numpy.ndarray>` data (:gh:`11803` by `Alex Rockhill`_)
3636
- Refactored internals of :func:`mne.read_annotations` (:gh:`11964` by `Paul Roujansky`_)
37+
- Enhance :func:`~mne.viz.plot_evoked_field` with a GUI that has controls for time, colormap, and contour lines (:gh:`11942` by `Marijn van Vliet`_)
3738

3839
Bugs
3940
~~~~

doc/visualization.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Visualization
1515

1616
Brain
1717
ClickableImage
18+
EvokedField
1819
Figure3D
1920
add_background_image
2021
centers_to_edges
@@ -108,8 +109,9 @@ UI Events
108109
unlink
109110
disable_ui_events
110111
UIEvent
112+
ColormapRange
113+
Contours
111114
FigureClosing
112-
TimeChange
113115
PlaybackSpeed
114-
ColormapRange
116+
TimeChange
115117
VertexSelect

examples/datasets/spm_faces_dataset_sgskip.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
# %%
4242
# Load and filter data, set up epochs
4343

44-
raw_fname = spm_path / "SPM_CTF_MEG_example_faces%d_3D.ds"
44+
raw_fname = spm_path / "SPM_CTF_MEG_example_faces1_3D.ds"
4545

46-
raw = io.read_raw_ctf(raw_fname % 1, preload=True) # Take first run
46+
raw = io.read_raw_ctf(raw_fname, preload=True) # Take first run
4747
# Here to save memory and time we'll downsample heavily -- this is not
4848
# advised for real data as it can effectively jitter events!
4949
raw.resample(120.0, npad="auto")
@@ -112,7 +112,7 @@
112112
evoked[0], trans_fname, subject="spm", subjects_dir=subjects_dir, n_jobs=None
113113
)
114114

115-
evoked[0].plot_field(maps, time=0.170)
115+
evoked[0].plot_field(maps, time=0.170, time_viewer=False)
116116

117117
# %%
118118
# Look at the whitened evoked daat

examples/visualization/mne_helmet.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@
4545
surfaces="pial",
4646
coord_frame="mri",
4747
)
48-
evoked.plot_field(maps, time=time, fig=fig, time_label=None, vmax=5e-13)
48+
evoked.plot_field(
49+
maps, time=time, fig=fig, time_label=None, vmax=5e-13, time_viewer=False
50+
)
4951
mne.viz.set_3d_view(
5052
fig,
5153
azimuth=40,

mne/evoked.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,11 @@ def plot_field(
662662
vmax=None,
663663
n_contours=21,
664664
*,
665+
show_density=True,
666+
alpha=None,
667+
interpolation="nearest",
665668
interaction="terrain",
669+
time_viewer="auto",
666670
verbose=None,
667671
):
668672
return plot_evoked_field(
@@ -674,7 +678,11 @@ def plot_field(
674678
fig=fig,
675679
vmax=vmax,
676680
n_contours=n_contours,
681+
show_density=show_density,
682+
alpha=alpha,
683+
interpolation=interpolation,
677684
interaction=interaction,
685+
time_viewer=time_viewer,
678686
verbose=verbose,
679687
)
680688

mne/viz/_3d.py

Lines changed: 58 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,15 @@
8383
_check_option,
8484
_to_rgb,
8585
)
86-
from ._3d_overlay import _LayeredMesh
8786
from .utils import (
88-
mne_analyze_colormap,
8987
_get_color_list,
9088
_get_cmap,
9189
plt_show,
9290
tight_layout,
9391
figure_nobar,
9492
_check_time_unit,
9593
)
96-
94+
from .evoked_field import EvokedField
9795

9896
verbose_dec = verbose
9997
FIDUCIAL_ORDER = (FIFF.FIFFV_POINT_LPA, FIFF.FIFFV_POINT_NASION, FIFF.FIFFV_POINT_RPA)
@@ -400,7 +398,11 @@ def plot_evoked_field(
400398
vmax=None,
401399
n_contours=21,
402400
*,
401+
show_density=True,
402+
alpha=None,
403+
interpolation="nearest",
403404
interaction="terrain",
405+
time_viewer="auto",
404406
verbose=None,
405407
):
406408
"""Plot MEG/EEG fields on head surface and helmet in 3D.
@@ -417,149 +419,79 @@ def plot_evoked_field(
417419
time_label : str | None
418420
How to print info about the time instant visualized.
419421
%(n_jobs)s
420-
fig : instance of Figure3D | None
422+
fig : Figure3D | mne.viz.Brain | None
421423
If None (default), a new figure will be created, otherwise it will
422424
plot into the given figure.
423425
424426
.. versionadded:: 0.20
425-
vmax : float | None
426-
Maximum intensity. Can be None to use the max(abs(data)).
427+
.. versionadded:: 1.4
428+
``fig`` can also be a ``Brain`` figure.
429+
vmax : float | dict | None
430+
Maximum intensity. Can be a dictionary with two entries ``"eeg"`` and ``"meg"``
431+
to specify separate values for EEG and MEG fields respectively. Can be
432+
``None`` to use the maximum value of the data.
427433
428434
.. versionadded:: 0.21
435+
.. versionadded:: 1.4
436+
``vmax`` can be a dictionary to specify separate values for EEG and
437+
MEG fields.
429438
n_contours : int
430439
The number of contours.
431440
432441
.. versionadded:: 0.21
442+
show_density : bool
443+
Whether to draw the field density as an overlay on top of the helmet/head
444+
surface. Defaults to ``True``.
445+
446+
.. versionadded:: 1.6
447+
alpha : float | dict | None
448+
Opacity of the meshes (between 0 and 1). Can be a dictionary with two
449+
entries ``"eeg"`` and ``"meg"`` to specify separate values for EEG and
450+
MEG fields respectively. Can be ``None`` to use 1.0 when a single field
451+
map is shown, or ``dict(eeg=1.0, meg=0.5)`` when both field maps are shown.
452+
453+
.. versionadded:: 1.4
454+
%(interpolation_brain_time)s
455+
456+
.. versionadded:: 1.6
433457
%(interaction_scene)s
434458
Defaults to ``'terrain'``.
435459
436460
.. versionadded:: 1.1
461+
time_viewer : bool | str
462+
Display time viewer GUI. Can also be ``"auto"``, which will mean
463+
``True`` if there is more than one time point and ``False`` otherwise.
464+
465+
.. versionadded:: 1.6
437466
%(verbose)s
438467
439468
Returns
440469
-------
441-
fig : instance of Figure3D
442-
The figure.
470+
fig : Figure3D | mne.viz.EvokedField
471+
Without the time viewer active, the figure is returned. With the time
472+
viewer active, an object is returned that can be used to control
473+
different aspects of the figure.
443474
"""
444-
# Update the backend
445-
from .backends.renderer import _get_renderer
446-
447-
types = [t for t in ["eeg", "grad", "mag"] if t in evoked]
448-
_validate_type(vmax, (None, "numeric"), "vmax")
449-
n_contours = _ensure_int(n_contours, "n_contours")
450-
_check_option("interaction", interaction, ["trackball", "terrain"])
451-
452-
time_idx = None
453-
if time is None:
454-
time = np.mean([evoked.get_peak(ch_type=t)[1] for t in types])
455-
del types
456-
457-
if not evoked.times[0] <= time <= evoked.times[-1]:
458-
raise ValueError("`time` (%0.3f) must be inside `evoked.times`" % time)
459-
time_idx = np.argmin(np.abs(evoked.times - time))
460-
461-
# Plot them
462-
alphas = [1.0, 0.5]
463-
colors = [(0.6, 0.6, 0.6), (1.0, 1.0, 1.0)]
464-
colormap = mne_analyze_colormap(format="vtk")
465-
colormap_lines = np.concatenate(
466-
[
467-
np.tile([0.0, 0.0, 255.0, 255.0], (127, 1)),
468-
np.tile([0.0, 0.0, 0.0, 255.0], (2, 1)),
469-
np.tile([255.0, 0.0, 0.0, 255.0], (127, 1)),
470-
]
475+
ef = EvokedField(
476+
evoked,
477+
surf_maps,
478+
time=time,
479+
time_label=time_label,
480+
n_jobs=n_jobs,
481+
fig=fig,
482+
vmax=vmax,
483+
n_contours=n_contours,
484+
alpha=alpha,
485+
show_density=show_density,
486+
interpolation=interpolation,
487+
interaction=interaction,
488+
time_viewer=time_viewer,
489+
verbose=verbose,
471490
)
472-
473-
renderer = _get_renderer(fig, bgcolor=(0.0, 0.0, 0.0), size=(600, 600))
474-
renderer.set_interaction(interaction)
475-
476-
for ii, this_map in enumerate(surf_maps):
477-
surf = this_map["surf"]
478-
map_data = this_map["data"]
479-
map_type = this_map["kind"]
480-
map_ch_names = this_map["ch_names"]
481-
482-
if map_type == "eeg":
483-
pick = pick_types(evoked.info, meg=False, eeg=True)
484-
else:
485-
pick = pick_types(evoked.info, meg=True, eeg=False, ref_meg=False)
486-
487-
ch_names = [evoked.ch_names[k] for k in pick]
488-
489-
set_ch_names = set(ch_names)
490-
set_map_ch_names = set(map_ch_names)
491-
if set_ch_names != set_map_ch_names:
492-
message = ["Channels in map and data do not match."]
493-
diff = set_map_ch_names - set_ch_names
494-
if len(diff):
495-
message += ["%s not in data file. " % list(diff)]
496-
diff = set_ch_names - set_map_ch_names
497-
if len(diff):
498-
message += ["%s not in map file." % list(diff)]
499-
raise RuntimeError(" ".join(message))
500-
501-
data = np.dot(map_data, evoked.data[pick, time_idx])
502-
503-
# Make a solid surface
504-
if vmax is None:
505-
vmax = np.max(np.abs(data))
506-
vmax = float(vmax)
507-
alpha = alphas[ii]
508-
mesh = _LayeredMesh(
509-
renderer=renderer,
510-
vertices=surf["rr"],
511-
triangles=surf["tris"],
512-
normals=surf["nn"],
513-
)
514-
mesh.map()
515-
color = _to_rgb(colors[ii], alpha=True)
516-
cmap = np.array(
517-
[
518-
(
519-
0,
520-
0,
521-
0,
522-
0,
523-
),
524-
color,
525-
]
526-
)
527-
ctable = np.round(cmap * 255).astype(np.uint8)
528-
mesh.add_overlay(
529-
scalars=np.ones(len(data)),
530-
colormap=ctable,
531-
rng=[0, 1],
532-
opacity=alpha,
533-
name="surf",
534-
)
535-
# Now show our field pattern
536-
mesh.add_overlay(
537-
scalars=data,
538-
colormap=colormap,
539-
rng=[-vmax, vmax],
540-
opacity=1.0,
541-
name="field",
542-
)
543-
544-
# And the field lines on top
545-
if n_contours > 0:
546-
renderer.contour(
547-
surface=surf,
548-
scalars=data,
549-
contours=n_contours,
550-
vmin=-vmax,
551-
vmax=vmax,
552-
opacity=alpha,
553-
colormap=colormap_lines,
554-
)
555-
556-
if time_label is not None:
557-
if "%" in time_label:
558-
time_label %= 1e3 * evoked.times[time_idx]
559-
renderer.text2d(x_window=0.01, y_window=0.01, text=time_label)
560-
renderer.set_camera(azimuth=10, elevation=60)
561-
renderer.show()
562-
return renderer.scene()
491+
if ef.time_viewer:
492+
return ef
493+
else:
494+
return ef._renderer.scene()
563495

564496

565497
@verbose

mne/viz/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"plot_evoked_joint",
3535
"plot_compare_evokeds",
3636
],
37+
"evoked_field": [
38+
"EvokedField",
39+
],
3740
"ica": [
3841
"plot_ica_scores",
3942
"plot_ica_sources",

0 commit comments

Comments
 (0)