578 lines
20 KiB
Python
578 lines
20 KiB
Python
"""Class to draw evoked MEG and EEG fieldlines, with a GUI to control the figure.
|
|
|
|
author: Marijn van Vliet <w.m.vanvliet@gmail.com>
|
|
"""
|
|
|
|
# Authors: The MNE-Python contributors.
|
|
# License: BSD-3-Clause
|
|
# Copyright the MNE-Python contributors.
|
|
|
|
from functools import partial
|
|
|
|
import numpy as np
|
|
from scipy.interpolate import interp1d
|
|
|
|
from .._fiff.pick import pick_types
|
|
from ..defaults import DEFAULTS
|
|
from ..utils import (
|
|
_auto_weakref,
|
|
_check_option,
|
|
_ensure_int,
|
|
_to_rgb,
|
|
_validate_type,
|
|
fill_doc,
|
|
)
|
|
from ._3d_overlay import _LayeredMesh
|
|
from .ui_events import (
|
|
ColormapRange,
|
|
Contours,
|
|
TimeChange,
|
|
disable_ui_events,
|
|
publish,
|
|
subscribe,
|
|
)
|
|
from .utils import mne_analyze_colormap
|
|
|
|
|
|
@fill_doc
|
|
class EvokedField:
|
|
"""Plot MEG/EEG fields on head surface and helmet in 3D.
|
|
|
|
Parameters
|
|
----------
|
|
evoked : instance of mne.Evoked
|
|
The evoked object.
|
|
surf_maps : list
|
|
The surface mapping information obtained with make_field_map.
|
|
time : float | None
|
|
The time point at which the field map shall be displayed. If None,
|
|
the average peak latency (across sensor types) is used.
|
|
time_label : str | None
|
|
How to print info about the time instant visualized.
|
|
%(n_jobs)s
|
|
fig : instance of Figure3D | None
|
|
If None (default), a new figure will be created, otherwise it will
|
|
plot into the given figure.
|
|
|
|
.. versionadded:: 0.20
|
|
vmax : float | dict | None
|
|
Maximum intensity. Can be a dictionary with two entries ``"eeg"`` and ``"meg"``
|
|
to specify separate values for EEG and MEG fields respectively. Can be
|
|
``None`` to use the maximum value of the data.
|
|
|
|
.. versionadded:: 0.21
|
|
.. versionadded:: 1.4
|
|
``vmax`` can be a dictionary to specify separate values for EEG and
|
|
MEG fields.
|
|
n_contours : int
|
|
The number of contours.
|
|
|
|
.. versionadded:: 0.21
|
|
show_density : bool
|
|
Whether to draw the field density as an overlay on top of the helmet/head
|
|
surface. Defaults to ``True``.
|
|
alpha : float | dict | None
|
|
Opacity of the meshes (between 0 and 1). Can be a dictionary with two
|
|
entries ``"eeg"`` and ``"meg"`` to specify separate values for EEG and
|
|
MEG fields respectively. Can be ``None`` to use 1.0 when a single field
|
|
map is shown, or ``dict(eeg=1.0, meg=0.5)`` when both field maps are shown.
|
|
|
|
.. versionadded:: 1.4
|
|
%(interpolation_brain_time)s
|
|
|
|
.. versionadded:: 1.6
|
|
%(interaction_scene)s
|
|
Defaults to ``'terrain'``.
|
|
|
|
.. versionadded:: 1.1
|
|
time_viewer : bool | str
|
|
Display time viewer GUI. Can also be ``"auto"``, which will mean
|
|
``True`` if there is more than one time point and ``False`` otherwise.
|
|
|
|
.. versionadded:: 1.6
|
|
%(verbose)s
|
|
|
|
Notes
|
|
-----
|
|
The figure will publish and subscribe to the following UI events:
|
|
|
|
* :class:`~mne.viz.ui_events.TimeChange`
|
|
* :class:`~mne.viz.ui_events.Contours`, ``kind="field_strength_meg" | "field_strength_eeg"``
|
|
* :class:`~mne.viz.ui_events.ColormapRange`, ``kind="field_strength_meg" | "field_strength_eeg"``
|
|
""" # noqa
|
|
|
|
def __init__(
|
|
self,
|
|
evoked,
|
|
surf_maps,
|
|
*,
|
|
time=None,
|
|
time_label="t = %0.0f ms",
|
|
n_jobs=None,
|
|
fig=None,
|
|
vmax=None,
|
|
n_contours=21,
|
|
show_density=True,
|
|
alpha=None,
|
|
interpolation="nearest",
|
|
interaction="terrain",
|
|
time_viewer="auto",
|
|
verbose=None,
|
|
):
|
|
from .backends.renderer import _get_3d_backend, _get_renderer
|
|
|
|
# Setup figure parameters
|
|
self._evoked = evoked
|
|
if time is None:
|
|
types = [t for t in ["eeg", "grad", "mag"] if t in evoked]
|
|
time = np.mean([evoked.get_peak(ch_type=t)[1] for t in types])
|
|
self._current_time = time
|
|
if not evoked.times[0] <= time <= evoked.times[-1]:
|
|
raise ValueError(f"`time` ({time:0.3f}) must be inside `evoked.times`")
|
|
self._time_label = time_label
|
|
|
|
self._vmax = _validate_type(vmax, (None, "numeric", dict), "vmax")
|
|
self._n_contours = _ensure_int(n_contours, "n_contours")
|
|
self._time_interpolation = _check_option(
|
|
"interpolation",
|
|
interpolation,
|
|
("linear", "nearest", "zero", "slinear", "quadratic", "cubic"),
|
|
)
|
|
self._interaction = _check_option(
|
|
"interaction", interaction, ["trackball", "terrain"]
|
|
)
|
|
|
|
surf_map_kinds = [surf_map["kind"] for surf_map in surf_maps]
|
|
if vmax is None:
|
|
self._vmax = {kind: None for kind in surf_map_kinds}
|
|
elif isinstance(vmax, dict):
|
|
for kind in surf_map_kinds:
|
|
if kind not in vmax:
|
|
raise ValueError(
|
|
f'No entry for "{kind}" found in the vmax dictionary'
|
|
)
|
|
self._vmax = vmax
|
|
else: # float value
|
|
self._vmax = {kind: vmax for kind in surf_map_kinds}
|
|
|
|
if alpha is None:
|
|
self._alpha = {
|
|
surf_map["kind"]: val for surf_map, val in zip(surf_maps, [1.0, 0.5])
|
|
}
|
|
elif isinstance(alpha, dict):
|
|
for kind in surf_map_kinds:
|
|
if kind not in alpha:
|
|
raise ValueError(
|
|
f'No entry for "{kind}" found in the alpha dictionary'
|
|
)
|
|
self._alpha = alpha
|
|
else: # float value
|
|
self._alpha = {kind: alpha for kind in surf_map_kinds}
|
|
|
|
self._colors = [(0.6, 0.6, 0.6), (1.0, 1.0, 1.0)]
|
|
self._colormap = mne_analyze_colormap(format="vtk")
|
|
self._colormap_lines = np.concatenate(
|
|
[
|
|
np.tile([0.0, 0.0, 255.0, 255.0], (127, 1)),
|
|
np.tile([0.0, 0.0, 0.0, 255.0], (2, 1)),
|
|
np.tile([255.0, 0.0, 0.0, 255.0], (127, 1)),
|
|
]
|
|
)
|
|
self._show_density = show_density
|
|
|
|
from ._brain import Brain
|
|
|
|
if isinstance(fig, Brain):
|
|
self._renderer = fig._renderer
|
|
self._in_brain_figure = True
|
|
if _get_3d_backend() == "notebook":
|
|
raise NotImplementedError(
|
|
"Plotting on top of an existing Brain figure "
|
|
"is currently not supported inside a notebook."
|
|
)
|
|
else:
|
|
self._renderer = _get_renderer(
|
|
fig, bgcolor=(0.0, 0.0, 0.0), size=(600, 600)
|
|
)
|
|
self._in_brain_figure = False
|
|
|
|
self.plotter = self._renderer.plotter
|
|
self.interaction = interaction
|
|
|
|
# Prepare the surface maps
|
|
self._surf_maps = [
|
|
self._prepare_surf_map(surf_map, color, self._alpha[surf_map["kind"]])
|
|
for surf_map, color in zip(surf_maps, self._colors)
|
|
]
|
|
|
|
# Do we want the time viewer?
|
|
if time_viewer == "auto":
|
|
time_viewer = len(evoked.times) > 1
|
|
self.time_viewer = time_viewer
|
|
|
|
# Configure UI events
|
|
@_auto_weakref
|
|
def current_time_func():
|
|
return self._current_time
|
|
|
|
self._widgets = dict()
|
|
if self.time_viewer:
|
|
# Draw widgets only if not inside a figure that already has them.
|
|
if (
|
|
not hasattr(self._renderer, "_widgets")
|
|
or "time_slider" not in self._renderer._widgets
|
|
):
|
|
self._renderer._enable_time_interaction(
|
|
self,
|
|
current_time_func=current_time_func,
|
|
times=evoked.times,
|
|
)
|
|
if not self._in_brain_figure or "time_slider" not in fig.widgets:
|
|
# Draw the time label
|
|
self._time_label = time_label
|
|
if time_label is not None:
|
|
if "%" in time_label:
|
|
time_label = time_label % np.round(1e3 * time)
|
|
self._time_label_actor = self._renderer.text2d(
|
|
x_window=0.01, y_window=0.01, text=time_label
|
|
)
|
|
self._configure_dock()
|
|
|
|
subscribe(self, "time_change", self._on_time_change)
|
|
subscribe(self, "colormap_range", self._on_colormap_range)
|
|
subscribe(self, "contours", self._on_contours)
|
|
|
|
if not self._in_brain_figure:
|
|
self._renderer.set_interaction(interaction)
|
|
self._renderer.set_camera(azimuth=10, elevation=60, distance="auto")
|
|
self._renderer.show()
|
|
|
|
def _prepare_surf_map(self, surf_map, color, alpha):
|
|
"""Compute all the data required to render a fieldlines map."""
|
|
if surf_map["kind"] == "eeg":
|
|
pick = pick_types(self._evoked.info, meg=False, eeg=True)
|
|
else:
|
|
pick = pick_types(self._evoked.info, meg=True, eeg=False, ref_meg=False)
|
|
|
|
evoked_ch_names = set([self._evoked.ch_names[k] for k in pick])
|
|
map_ch_names = set(surf_map["ch_names"])
|
|
if evoked_ch_names != map_ch_names:
|
|
message = ["Channels in map and data do not match."]
|
|
diff = map_ch_names - evoked_ch_names
|
|
if len(diff):
|
|
message += [f"{list(diff)} not in data file. "]
|
|
diff = evoked_ch_names - map_ch_names
|
|
if len(diff):
|
|
message += [f"{list(diff)} not in map file."]
|
|
raise RuntimeError(" ".join(message))
|
|
|
|
data = surf_map["data"] @ self._evoked.data[pick]
|
|
data_interp = interp1d(
|
|
self._evoked.times,
|
|
data,
|
|
kind=self._time_interpolation,
|
|
assume_sorted=True,
|
|
)
|
|
current_data = data_interp(self._current_time)
|
|
|
|
# Make a solid surface
|
|
surf = surf_map["surf"]
|
|
if self._in_brain_figure:
|
|
surf["rr"] *= 1000
|
|
map_vmax = self._vmax.get(surf_map["kind"])
|
|
if map_vmax is None:
|
|
map_vmax = float(np.max(current_data))
|
|
mesh = _LayeredMesh(
|
|
renderer=self._renderer,
|
|
vertices=surf["rr"],
|
|
triangles=surf["tris"],
|
|
normals=surf["nn"],
|
|
)
|
|
mesh.map()
|
|
color = _to_rgb(color, alpha=True)
|
|
cmap = np.array([(0, 0, 0, 0), color])
|
|
ctable = np.round(cmap * 255).astype(np.uint8)
|
|
mesh.add_overlay(
|
|
scalars=np.ones(len(current_data)),
|
|
colormap=ctable,
|
|
rng=[0, 1],
|
|
opacity=alpha,
|
|
name="surf",
|
|
)
|
|
|
|
# Show the field density
|
|
if self._show_density:
|
|
mesh.add_overlay(
|
|
scalars=current_data,
|
|
colormap=self._colormap,
|
|
rng=[-map_vmax, map_vmax],
|
|
opacity=1.0,
|
|
name="field",
|
|
)
|
|
|
|
# And the field lines on top
|
|
if self._n_contours > 1:
|
|
contours = np.linspace(-map_vmax, map_vmax, self._n_contours)
|
|
contours_actor, _ = self._renderer.contour(
|
|
surface=surf,
|
|
scalars=current_data,
|
|
contours=contours,
|
|
vmin=-map_vmax,
|
|
vmax=map_vmax,
|
|
colormap=self._colormap_lines,
|
|
)
|
|
else:
|
|
contours = None # noqa
|
|
contours_actor = None
|
|
|
|
return dict(
|
|
pick=pick,
|
|
data=data,
|
|
data_interp=data_interp,
|
|
map_kind=surf_map["kind"],
|
|
mesh=mesh,
|
|
contours=contours,
|
|
contours_actor=contours_actor,
|
|
surf=surf,
|
|
map_vmax=map_vmax,
|
|
)
|
|
|
|
def _update(self):
|
|
"""Update the figure to reflect the current settings."""
|
|
for surf_map in self._surf_maps:
|
|
current_data = surf_map["data_interp"](self._current_time)
|
|
surf_map["mesh"].update_overlay(name="field", scalars=current_data)
|
|
|
|
if surf_map["contours"] is not None:
|
|
self._renderer.plotter.remove_actor(
|
|
surf_map["contours_actor"], render=False
|
|
)
|
|
if self._n_contours > 1:
|
|
surf_map["contours_actor"], _ = self._renderer.contour(
|
|
surface=surf_map["surf"],
|
|
scalars=current_data,
|
|
contours=surf_map["contours"],
|
|
vmin=-surf_map["map_vmax"],
|
|
vmax=surf_map["map_vmax"],
|
|
colormap=self._colormap_lines,
|
|
)
|
|
if self._time_label is not None:
|
|
if hasattr(self, "_time_label_actor"):
|
|
self._renderer.plotter.remove_actor(
|
|
self._time_label_actor, render=False
|
|
)
|
|
time_label = self._time_label
|
|
if "%" in self._time_label:
|
|
time_label = self._time_label % np.round(1e3 * self._current_time)
|
|
self._time_label_actor = self._renderer.text2d(
|
|
x_window=0.01, y_window=0.01, text=time_label
|
|
)
|
|
|
|
self._renderer.plotter.update()
|
|
|
|
def _configure_dock(self):
|
|
"""Configure the widgets shown in the dock on the left."""
|
|
r = self._renderer
|
|
|
|
if not hasattr(r, "_dock"):
|
|
r._dock_initialize()
|
|
|
|
# Fieldline configuration
|
|
layout = r._dock_add_group_box("Fieldlines")
|
|
|
|
if self._show_density:
|
|
r._dock_add_label(value="max value", align=True, layout=layout)
|
|
|
|
@_auto_weakref
|
|
def _callback(vmax, kind, scaling):
|
|
self.set_vmax(vmax / scaling, kind=kind)
|
|
|
|
for surf_map in self._surf_maps:
|
|
if surf_map["map_kind"] == "meg":
|
|
scaling = DEFAULTS["scalings"]["grad"]
|
|
else:
|
|
scaling = DEFAULTS["scalings"]["eeg"]
|
|
rng = [0, np.max(np.abs(surf_map["data"])) * scaling]
|
|
hlayout = r._dock_add_layout(vertical=False)
|
|
|
|
self._widgets[f"vmax_slider_{surf_map['map_kind']}"] = (
|
|
r._dock_add_slider(
|
|
name=surf_map["map_kind"].upper(),
|
|
value=surf_map["map_vmax"] * scaling,
|
|
rng=rng,
|
|
callback=partial(
|
|
_callback, kind=surf_map["map_kind"], scaling=scaling
|
|
),
|
|
double=True,
|
|
layout=hlayout,
|
|
)
|
|
)
|
|
self._widgets[f"vmax_spin_{surf_map['map_kind']}"] = (
|
|
r._dock_add_spin_box(
|
|
name="",
|
|
value=surf_map["map_vmax"] * scaling,
|
|
rng=rng,
|
|
callback=partial(
|
|
_callback, kind=surf_map["map_kind"], scaling=scaling
|
|
),
|
|
layout=hlayout,
|
|
)
|
|
)
|
|
r._layout_add_widget(layout, hlayout)
|
|
|
|
hlayout = r._dock_add_layout(vertical=False)
|
|
r._dock_add_label(
|
|
value="Rescale",
|
|
align=True,
|
|
layout=hlayout,
|
|
)
|
|
r._dock_add_button(
|
|
name="↺",
|
|
callback=self._rescale,
|
|
layout=hlayout,
|
|
style="toolbutton",
|
|
)
|
|
r._layout_add_widget(layout, hlayout)
|
|
|
|
self._widgets["contours"] = r._dock_add_spin_box(
|
|
name="Contour lines",
|
|
value=21,
|
|
rng=[0, 99],
|
|
step=1,
|
|
double=False,
|
|
callback=self.set_contours,
|
|
layout=layout,
|
|
)
|
|
r._dock_finalize()
|
|
|
|
def _on_time_change(self, event):
|
|
"""Respond to time_change UI event."""
|
|
new_time = np.clip(event.time, self._evoked.times[0], self._evoked.times[-1])
|
|
if new_time == self._current_time:
|
|
return
|
|
self._current_time = new_time
|
|
self._update()
|
|
|
|
def _on_colormap_range(self, event):
|
|
"""Response to the colormap_range UI event."""
|
|
if event.kind == "field_strength_meg":
|
|
kind = "meg"
|
|
elif event.kind == "field_strength_eeg":
|
|
kind = "eeg"
|
|
else:
|
|
return
|
|
|
|
for surf_map in self._surf_maps:
|
|
if surf_map["map_kind"] == kind:
|
|
break
|
|
else:
|
|
# No field map currently shown of the requested type.
|
|
return
|
|
|
|
vmin = event.fmin
|
|
vmax = event.fmax
|
|
surf_map["contours"] = np.linspace(vmin, vmax, self._n_contours)
|
|
|
|
if self._show_density:
|
|
surf_map["mesh"].update_overlay(name="field", rng=[vmin, vmax])
|
|
# Update the GUI widgets
|
|
if kind == "meg":
|
|
scaling = DEFAULTS["scalings"]["grad"]
|
|
else:
|
|
scaling = DEFAULTS["scalings"]["eeg"]
|
|
with disable_ui_events(self):
|
|
widget = self._widgets.get(f"vmax_slider_{kind}", None)
|
|
if widget is not None:
|
|
widget.set_value(vmax * scaling)
|
|
widget = self._widgets.get(f"vmax_spin_{kind}", None)
|
|
if widget is not None:
|
|
widget.set_value(vmax * scaling)
|
|
|
|
self._update()
|
|
|
|
def _on_contours(self, event):
|
|
"""Respond to the contours UI event."""
|
|
if event.kind == "field_strength_meg":
|
|
kind = "meg"
|
|
elif event.kind == "field_strength_eeg":
|
|
kind = "eeg"
|
|
else:
|
|
return
|
|
|
|
for surf_map in self._surf_maps:
|
|
if surf_map["map_kind"] == kind:
|
|
break
|
|
surf_map["contours"] = event.contours
|
|
self._n_contours = len(event.contours)
|
|
with disable_ui_events(self):
|
|
if "contours" in self._widgets:
|
|
self._widgets["contours"].set_value(len(event.contours))
|
|
self._update()
|
|
|
|
def set_time(self, time):
|
|
"""Set the time to display (in seconds).
|
|
|
|
Parameters
|
|
----------
|
|
time : float
|
|
The time to show, in seconds.
|
|
"""
|
|
if self._evoked.times[0] <= time <= self._evoked.times[-1]:
|
|
publish(self, TimeChange(time=time))
|
|
else:
|
|
raise ValueError(
|
|
f"Requested time ({time} s) is outside the range of "
|
|
f"available times ({self._evoked.times[0]}-{self._evoked.times[-1]} s)."
|
|
)
|
|
|
|
def set_contours(self, n_contours):
|
|
"""Adjust the number of contour lines to use when drawing the fieldlines.
|
|
|
|
Parameters
|
|
----------
|
|
n_contours : int
|
|
The number of contour lines to use.
|
|
"""
|
|
for surf_map in self._surf_maps:
|
|
publish(
|
|
self,
|
|
Contours(
|
|
kind=f"field_strength_{surf_map['map_kind']}",
|
|
contours=np.linspace(
|
|
-surf_map["map_vmax"], surf_map["map_vmax"], n_contours
|
|
).tolist(),
|
|
),
|
|
)
|
|
|
|
def set_vmax(self, vmax, kind="meg"):
|
|
"""Change the color range of the density maps.
|
|
|
|
Parameters
|
|
----------
|
|
vmax : float
|
|
The new maximum value of the color range.
|
|
kind : 'meg' | 'eeg'
|
|
Which field map to apply the new color range to.
|
|
"""
|
|
_check_option("type", kind, ["eeg", "meg"])
|
|
for surf_map in self._surf_maps:
|
|
if surf_map["map_kind"] == kind:
|
|
publish(
|
|
self,
|
|
ColormapRange(
|
|
kind=f"field_strength_{kind}",
|
|
fmin=-vmax,
|
|
fmax=vmax,
|
|
),
|
|
)
|
|
break
|
|
else:
|
|
raise ValueError(f"No {type.upper()} field map currently shown.")
|
|
|
|
def _rescale(self):
|
|
"""Rescale the fieldlines and density maps to the current time point."""
|
|
for surf_map in self._surf_maps:
|
|
current_data = surf_map["data_interp"](self._current_time)
|
|
vmax = float(np.max(current_data))
|
|
self.set_vmax(vmax, kind=surf_map["map_kind"])
|