Skip to content

Commit c9d2006

Browse files
scott-hubertylarsonerpre-commit-ci[bot]drammockmscheltienne
authored
ENH: eyetracking plot_heatmap function (#11798)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel McCloy <dan@mccloy.info> Co-authored-by: Mathieu Scheltienne <mathieu.scheltienne@gmail.com>
1 parent a5eb548 commit c9d2006

File tree

10 files changed

+357
-14
lines changed

10 files changed

+357
-14
lines changed

doc/api/visualization.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,21 @@ Visualization
8888
get_browser_backend
8989
use_browser_backend
9090

91+
Eyetracking
92+
-----------
93+
94+
.. currentmodule:: mne.viz.eyetracking
95+
96+
:py:mod:`mne.viz.eyetracking`:
97+
98+
.. automodule:: mne.viz.eyetracking
99+
:no-members:
100+
:no-inherited-members:
101+
.. autosummary::
102+
:toctree: generated/
103+
104+
plot_gaze
105+
91106
UI Events
92107
---------
93108

doc/documentation/datasets.rst

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -481,16 +481,34 @@ EYELINK
481481
=======
482482
:func:`mne.datasets.eyelink.data_path`
483483

484-
A small example dataset from a pupillary light reflex experiment. Both EEG (EGI) and
485-
eye-tracking (SR Research EyeLink; ASCII format) data were recorded and stored in
486-
separate files. 1 participant fixated on the screen while short light flashes appeared.
487-
Event onsets were recorded by a photodiode attached to the screen and were
488-
sent to both the EEG and eye-tracking systems.
484+
Two small example datasets of eye-tracking data from SR Research EyeLink.
485+
486+
EEG-Eyetracking
487+
^^^^^^^^^^^^^^^
488+
:func:`mne.datasets.eyelink.data_path`. Data exists at ``/eeg-et/``.
489+
490+
Contains both EEG (EGI) and eye-tracking (ASCII format) data recorded from a
491+
pupillary light reflex experiment, stored in separate files. 1 participant fixated
492+
on the screen while short light flashes appeared. Event onsets were recorded by a
493+
photodiode attached to the screen and were sent to both the EEG and eye-tracking
494+
systems.
489495

490496
.. topic:: Examples
491497

492498
* :ref:`tut-eyetrack`
493499

500+
Freeviewing
501+
^^^^^^^^^^^
502+
:func:`mne.datasets.eyelink.data_path`. Data exists at ``/freeviewing/``.
503+
504+
Contains eye-tracking data (ASCII format) from 1 participant who was free-viewing a
505+
video of a natural scene. In some videos, the natural scene was pixelated such that
506+
the people in the scene were unrecognizable.
507+
508+
.. topic:: Examples
509+
510+
* :ref:`tut-eyetrack-heatmap`
511+
494512
References
495513
==========
496514

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
.. _tut-eyetrack-heatmap:
4+
5+
=============================================
6+
Plotting eye-tracking heatmaps in MNE-Python
7+
=============================================
8+
9+
This tutorial covers plotting eye-tracking position data as a heatmap.
10+
11+
.. seealso::
12+
13+
:ref:`tut-importing-eyetracking-data`
14+
:ref:`tut-eyetrack`
15+
16+
"""
17+
18+
# %%
19+
# Data loading
20+
# ------------
21+
#
22+
# As usual we start by importing the modules we need and loading some
23+
# :ref:`example data <eyelink-dataset>`: eye-tracking data recorded from SR research's
24+
# ``'.asc'`` file format.
25+
26+
27+
import matplotlib.pyplot as plt
28+
29+
import mne
30+
from mne.viz.eyetracking import plot_gaze
31+
32+
task_fpath = mne.datasets.eyelink.data_path() / "freeviewing"
33+
et_fpath = task_fpath / "sub-01_task-freeview_eyetrack.asc"
34+
stim_fpath = task_fpath / "stim" / "naturalistic.png"
35+
36+
raw = mne.io.read_raw_eyelink(et_fpath)
37+
38+
# %%
39+
# Process and epoch the data
40+
# --------------------------
41+
#
42+
# First we will interpolate missing data during blinks and epoch the data.
43+
44+
mne.preprocessing.eyetracking.interpolate_blinks(raw, interpolate_gaze=True)
45+
raw.annotations.rename({"dvns": "natural"}) # more intuitive
46+
event_ids = {"natural": 1}
47+
events, event_dict = mne.events_from_annotations(raw, event_id=event_ids)
48+
49+
epochs = mne.Epochs(
50+
raw, events=events, event_id=event_dict, tmin=0, tmax=20, baseline=None
51+
)
52+
53+
54+
# %%
55+
# Plot a heatmap of the eye-tracking data
56+
# ---------------------------------------
57+
#
58+
# To make a heatmap of the eye-tracking data, we can use the function
59+
# :func:`~mne.viz.eyetracking.plot_gaze`. We will need to define the dimensions of our
60+
# canvas; for this file, the eye position data are reported in pixels, so we'll use the
61+
# screen resolution of the participant screen (1920x1080) as the width and height. We
62+
# can also use the sigma parameter to smooth the plot.
63+
64+
px_width, px_height = 1920, 1080
65+
cmap = plt.get_cmap("viridis")
66+
plot_gaze(epochs["natural"], width=px_width, height=px_height, cmap=cmap, sigma=50)
67+
68+
# %%
69+
# Overlaying plots with images
70+
# ----------------------------
71+
#
72+
# We can use matplotlib to plot gaze heatmaps on top of stimuli images. We'll
73+
# customize a :class:`~matplotlib.colors.Colormap` to make some values of the heatmap
74+
# completely transparent. We'll then use the ``vlim`` parameter to force the heatmap to
75+
# start at a value greater than the darkest value in our previous heatmap, which will
76+
# make the darkest colors of the heatmap transparent.
77+
78+
cmap.set_under("k", alpha=0) # make the lowest values transparent
79+
ax = plt.subplot()
80+
ax.imshow(plt.imread(stim_fpath))
81+
plot_gaze(
82+
epochs["natural"],
83+
width=px_width,
84+
height=px_height,
85+
vlim=(0.0003, None),
86+
sigma=50,
87+
cmap=cmap,
88+
axes=ax,
89+
)

mne/datasets/config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,9 @@
345345

346346
# eyelink dataset
347347
MNE_DATASETS["eyelink"] = dict(
348-
archive_name="eeg-eyetrack_data.zip",
349-
hash="md5:c4fc788fe01737e08e9086c90cab642d",
350-
url=("https://osf.io/63fjm/download?version=1"),
351-
folder_name="eyelink-example-data",
348+
archive_name="MNE-eyelink-data.zip",
349+
hash="md5:68a6323ef17d655f1a659c3290ee1c3f",
350+
url=("https://osf.io/xsu4g/download?version=1"),
351+
folder_name="MNE-eyelink-data",
352352
config_key="MNE_DATASETS_EYELINK_PATH",
353353
)

mne/utils/docs.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,14 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
743743
``pos_lims``, as the surface plot must show the magnitude.
744744
"""
745745

746+
docdict[
747+
"cmap"
748+
] = """
749+
cmap : matplotlib colormap | str | None
750+
The :class:`~matplotlib.colors.Colormap` to use. Defaults to ``None``, which
751+
will use the matplotlib default colormap.
752+
"""
753+
746754
docdict[
747755
"cmap_topomap"
748756
] = """

mne/viz/eyetracking/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Eye-tracking visualization routines."""
2+
#
3+
# License: BSD-3-Clause
4+
5+
from .heatmap import plot_gaze

mne/viz/eyetracking/heatmap.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Authors: Scott Huberty <seh33@uw.edu>
2+
#
3+
# License: BSD-3-Clause
4+
5+
import numpy as np
6+
from scipy.ndimage import gaussian_filter
7+
8+
from ...utils import _ensure_int, _validate_type, fill_doc, logger
9+
from ..utils import plt_show
10+
11+
12+
@fill_doc
13+
def plot_gaze(
14+
epochs,
15+
width,
16+
height,
17+
*,
18+
sigma=25,
19+
cmap=None,
20+
alpha=1.0,
21+
vlim=(None, None),
22+
axes=None,
23+
show=True,
24+
):
25+
"""Plot a heatmap of eyetracking gaze data.
26+
27+
Parameters
28+
----------
29+
epochs : instance of Epochs
30+
The :class:`~mne.Epochs` object containing eyegaze channels.
31+
width : int
32+
The width dimension of the plot canvas. For example, if the eyegaze data units
33+
are pixels, and the participant screen resolution was 1920x1080, then the width
34+
should be 1920.
35+
height : int
36+
The height dimension of the plot canvas. For example, if the eyegaze data units
37+
are pixels, and the participant screen resolution was 1920x1080, then the height
38+
should be 1080.
39+
sigma : float | None
40+
The amount of Gaussian smoothing applied to the heatmap data (standard
41+
deviation in pixels). If ``None``, no smoothing is applied. Default is 25.
42+
%(cmap)s
43+
alpha : float
44+
The opacity of the heatmap (default is 1).
45+
%(vlim_plot_topomap)s
46+
%(axes_plot_topomap)s
47+
%(show)s
48+
49+
Returns
50+
-------
51+
fig : instance of Figure
52+
The resulting figure object for the heatmap plot.
53+
54+
Notes
55+
-----
56+
.. versionadded:: 1.6
57+
"""
58+
from mne import BaseEpochs
59+
from mne._fiff.pick import _picks_to_idx
60+
61+
_validate_type(epochs, BaseEpochs, "epochs")
62+
_validate_type(alpha, "numeric", "alpha")
63+
_validate_type(sigma, ("numeric", None), "sigma")
64+
width = _ensure_int(width, "width")
65+
height = _ensure_int(height, "height")
66+
67+
pos_picks = _picks_to_idx(epochs.info, "eyegaze")
68+
gaze_data = epochs.get_data(picks=pos_picks)
69+
gaze_ch_loc = np.array([epochs.info["chs"][idx]["loc"] for idx in pos_picks])
70+
x_data = gaze_data[:, np.where(gaze_ch_loc[:, 4] == -1)[0], :]
71+
y_data = gaze_data[:, np.where(gaze_ch_loc[:, 4] == 1)[0], :]
72+
73+
if x_data.shape[1] > 1: # binocular recording. Average across eyes
74+
logger.info("Detected binocular recording. Averaging positions across eyes.")
75+
x_data = np.nanmean(x_data, axis=1) # shape (n_epochs, n_samples)
76+
y_data = np.nanmean(y_data, axis=1)
77+
canvas = np.vstack((x_data.flatten(), y_data.flatten())) # shape (2, n_samples)
78+
79+
# Create 2D histogram
80+
# Bin into image-like format
81+
hist, _, _ = np.histogram2d(
82+
canvas[1, :],
83+
canvas[0, :],
84+
bins=(height, width),
85+
range=[[0, height], [0, width]],
86+
)
87+
# Convert density from samples to seconds
88+
hist /= epochs.info["sfreq"]
89+
# Smooth the heatmap
90+
if sigma:
91+
hist = gaussian_filter(hist, sigma=sigma)
92+
93+
return _plot_heatmap_array(
94+
hist,
95+
width=width,
96+
height=height,
97+
cmap=cmap,
98+
alpha=alpha,
99+
vmin=vlim[0],
100+
vmax=vlim[1],
101+
axes=axes,
102+
show=show,
103+
)
104+
105+
106+
def _plot_heatmap_array(
107+
data,
108+
width,
109+
height,
110+
cmap=None,
111+
alpha=None,
112+
vmin=None,
113+
vmax=None,
114+
axes=None,
115+
show=True,
116+
):
117+
"""Plot a heatmap of eyetracking gaze data from a numpy array."""
118+
import matplotlib.pyplot as plt
119+
120+
# Prepare axes
121+
if axes is not None:
122+
from matplotlib.axes import Axes
123+
124+
_validate_type(axes, Axes, "axes")
125+
ax = axes
126+
fig = ax.get_figure()
127+
else:
128+
fig, ax = plt.subplots(constrained_layout=True)
129+
130+
ax.set_title("Gaze heatmap")
131+
ax.set_xlabel("X position")
132+
ax.set_ylabel("Y position")
133+
134+
# Prepare the heatmap
135+
alphas = 1 if alpha is None else alpha
136+
vmin = np.nanmin(data) if vmin is None else vmin
137+
vmax = np.nanmax(data) if vmax is None else vmax
138+
extent = [0, width, height, 0] # origin is the top left of the screen
139+
140+
# Plot heatmap
141+
im = ax.imshow(
142+
data,
143+
aspect="equal",
144+
cmap=cmap,
145+
alpha=alphas,
146+
extent=extent,
147+
origin="upper",
148+
vmin=vmin,
149+
vmax=vmax,
150+
)
151+
152+
# Prepare the colorbar
153+
fig.colorbar(im, ax=ax, shrink=0.6, label="Dwell time (seconds)")
154+
plt_show(show)
155+
return fig

mne/viz/eyetracking/tests/__init__.py

Whitespace-only changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Authors: Scott Huberty <seh33@uw.edu>
2+
#
3+
# License: Simplified BSD
4+
5+
import matplotlib.pyplot as plt
6+
import numpy as np
7+
import pytest
8+
9+
import mne
10+
11+
12+
@pytest.mark.parametrize("axes", [None, True])
13+
def test_plot_heatmap(axes):
14+
"""Test plot_gaze."""
15+
# Create a toy epochs instance
16+
info = info = mne.create_info(
17+
ch_names=["xpos", "ypos"], sfreq=100, ch_types="eyegaze"
18+
)
19+
# simulate a steady fixation at the center of the screen
20+
width, height = (1920, 1080)
21+
shape = (1, 100) # x or y, time
22+
data = np.vstack([np.full(shape, width / 2), np.full(shape, height / 2)])
23+
epochs = mne.EpochsArray(data[None, ...], info)
24+
epochs.info["chs"][0]["loc"][4] = -1
25+
epochs.info["chs"][1]["loc"][4] = 1
26+
27+
if axes:
28+
axes = plt.subplot()
29+
fig = mne.viz.eyetracking.plot_gaze(
30+
epochs, width=width, height=height, axes=axes, cmap="Greys", sigma=None
31+
)
32+
img = fig.axes[0].images[0].get_array()
33+
# We simulated a 2D histogram where only the central pixel (960, 540) was active
34+
assert img.T[width // 2, height // 2] == 1 # central pixel is active
35+
assert np.sum(img) == 1 # only the central pixel should be active

0 commit comments

Comments
 (0)