-
Notifications
You must be signed in to change notification settings - Fork 1.4k
ENH: eyetracking plot_heatmap function #11798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
40 commits
Select commit
Hold shift + click to select a range
4b256c6
ENH: eyetracking plot_heatmap function
scott-huberty 318d974
FIX: next matplotlib
scott-huberty 895f2ea
WIP: refactor heatmap code
scott-huberty e37dcd3
Merge remote-tracking branch 'upstream/main' into plot_gaze
scott-huberty d8575a9
Merge remote-tracking branch 'upstream/main' into plot_gaze
scott-huberty 320fc39
ENH, DOC: refactor plot_gaze and add to API doc
scott-huberty 09ce77d
DOC: update eyelink dataset and its description
scott-huberty 26888cd
DOC: add a new eyetracking tutorial
scott-huberty 2cfc3e1
TST: remove requires_pandas decorator from pytest
scott-huberty 0e8985f
DOC: fix typo
scott-huberty 26054f0
FIX, DOC: add alpha parameter to API docstring
scott-huberty aec89f8
DOC: in tutorial, dont baseline correct eyegaze channels
scott-huberty dd65afe
Merge branch 'main' into plot_gaze
larsoner 29eb623
FIX: Need module
larsoner 324d74e
Merge remote-tracking branch 'upstream/main' into plot_gaze
scott-huberty 6962df9
FIX: Code suggestions from Eric
scott-huberty 20e3056
TST: add a test
scott-huberty 6bedba6
Apply suggestions from code review [ci skip]
scott-huberty d0a2caa
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] c421d50
FIX, DOC: Code suggestions from eric and mne.utils.doc addition
scott-huberty 379abf0
FIX: doc_dict addition wasnt in alphabetical order
scott-huberty 6531e53
DOC: be more memory efficient in eyetracking tutorial
scott-huberty 3b17ec1
FIX: obvious typo....
scott-huberty 6085bc5
FIX, DOC: More doc fixes...
scott-huberty 8305a1d
Apply suggestions from code review [ci skip]
scott-huberty baaa7bc
FIX: fixed a mistake in _ensure_int parameter
scott-huberty b5c6079
Simplify tutorial
scott-huberty 093a003
Move new tutorial to examples
scott-huberty 7c693db
DOC: Rename tutorial, remove 30_ that was prepended to filename
scott-huberty bba5d7d
FIX: add py extension back to tutorial filename
scott-huberty 927fdad
STY: remove unnecessary transpose
scott-huberty 0c5b8f1
TST: set sigma to None in test and check that fig data match input data
scott-huberty df33e33
Merge remote-tracking branch 'upstream/main' into plot_gaze
scott-huberty 7ecc759
Merge remote-tracking branch 'upstream/main' into plot_gaze
scott-huberty 9896f2b
DOC, STY: split Eyelink dataset description
scott-huberty 32a3e64
TST: Make test more direct
scott-huberty 5add060
DOC: minor documentation revisions suggested by Dan
scott-huberty ea7e653
Update examples/visualization/eyetracking_plot_heatmap.py
drammock 0e3e495
Merge branch 'main' into plot_gaze
larsoner d89ef77
FIX: Order
larsoner File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
# -*- coding: utf-8 -*- | ||
""" | ||
.. _tut-eyetrack-heatmap: | ||
|
||
============================================= | ||
Plotting eye-tracking heatmaps in MNE-Python | ||
============================================= | ||
|
||
This tutorial covers plotting eye-tracking position data as a heatmap. | ||
|
||
.. seealso:: | ||
|
||
:ref:`tut-importing-eyetracking-data` | ||
:ref:`tut-eyetrack` | ||
|
||
""" | ||
|
||
# %% | ||
# Data loading | ||
# ------------ | ||
# | ||
# As usual we start by importing the modules we need and loading some | ||
# :ref:`example data <eyelink-dataset>`: eye-tracking data recorded from SR research's | ||
# ``'.asc'`` file format. | ||
|
||
|
||
import matplotlib.pyplot as plt | ||
|
||
import mne | ||
from mne.viz.eyetracking import plot_gaze | ||
|
||
task_fpath = mne.datasets.eyelink.data_path() / "freeviewing" | ||
et_fpath = task_fpath / "sub-01_task-freeview_eyetrack.asc" | ||
stim_fpath = task_fpath / "stim" / "naturalistic.png" | ||
|
||
raw = mne.io.read_raw_eyelink(et_fpath) | ||
|
||
# %% | ||
# Process and epoch the data | ||
# -------------------------- | ||
# | ||
# First we will interpolate missing data during blinks and epoch the data. | ||
|
||
mne.preprocessing.eyetracking.interpolate_blinks(raw, interpolate_gaze=True) | ||
raw.annotations.rename({"dvns": "natural"}) # more intuitive | ||
event_ids = {"natural": 1} | ||
events, event_dict = mne.events_from_annotations(raw, event_id=event_ids) | ||
|
||
epochs = mne.Epochs( | ||
raw, events=events, event_id=event_dict, tmin=0, tmax=20, baseline=None | ||
) | ||
|
||
|
||
# %% | ||
# Plot a heatmap of the eye-tracking data | ||
# --------------------------------------- | ||
# | ||
# To make a heatmap of the eye-tracking data, we can use the function | ||
# :func:`~mne.viz.eyetracking.plot_gaze`. We will need to define the dimensions of our | ||
# canvas; for this file, the eye position data are reported in pixels, so we'll use the | ||
# screen resolution of the participant screen (1920x1080) as the width and height. We | ||
# can also use the sigma parameter to smooth the plot. | ||
|
||
px_width, px_height = 1920, 1080 | ||
cmap = plt.get_cmap("viridis") | ||
plot_gaze(epochs["natural"], width=px_width, height=px_height, cmap=cmap, sigma=50) | ||
|
||
# %% | ||
# Overlaying plots with images | ||
# ---------------------------- | ||
# | ||
# We can use matplotlib to plot gaze heatmaps on top of stimuli images. We'll | ||
# customize a :class:`~matplotlib.colors.Colormap` to make some values of the heatmap | ||
# completely transparent. We'll then use the ``vlim`` parameter to force the heatmap to | ||
# start at a value greater than the darkest value in our previous heatmap, which will | ||
# make the darkest colors of the heatmap transparent. | ||
|
||
cmap.set_under("k", alpha=0) # make the lowest values transparent | ||
ax = plt.subplot() | ||
ax.imshow(plt.imread(stim_fpath)) | ||
plot_gaze( | ||
epochs["natural"], | ||
width=px_width, | ||
height=px_height, | ||
vlim=(0.0003, None), | ||
sigma=50, | ||
cmap=cmap, | ||
axes=ax, | ||
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
"""Eye-tracking visualization routines.""" | ||
# | ||
# License: BSD-3-Clause | ||
|
||
from .heatmap import plot_gaze |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
# Authors: Scott Huberty <seh33@uw.edu> | ||
# | ||
# License: BSD-3-Clause | ||
|
||
import numpy as np | ||
from scipy.ndimage import gaussian_filter | ||
|
||
from ...utils import _ensure_int, _validate_type, fill_doc, logger | ||
from ..utils import plt_show | ||
|
||
|
||
@fill_doc | ||
def plot_gaze( | ||
epochs, | ||
width, | ||
height, | ||
*, | ||
sigma=25, | ||
cmap=None, | ||
alpha=1.0, | ||
vlim=(None, None), | ||
axes=None, | ||
show=True, | ||
): | ||
"""Plot a heatmap of eyetracking gaze data. | ||
|
||
Parameters | ||
---------- | ||
epochs : instance of Epochs | ||
The :class:`~mne.Epochs` object containing eyegaze channels. | ||
width : int | ||
The width dimension of the plot canvas. For example, if the eyegaze data units | ||
are pixels, and the participant screen resolution was 1920x1080, then the width | ||
should be 1920. | ||
height : int | ||
The height dimension of the plot canvas. For example, if the eyegaze data units | ||
are pixels, and the participant screen resolution was 1920x1080, then the height | ||
should be 1080. | ||
sigma : float | None | ||
The amount of Gaussian smoothing applied to the heatmap data (standard | ||
deviation in pixels). If ``None``, no smoothing is applied. Default is 25. | ||
%(cmap)s | ||
alpha : float | ||
The opacity of the heatmap (default is 1). | ||
%(vlim_plot_topomap)s | ||
%(axes_plot_topomap)s | ||
%(show)s | ||
|
||
Returns | ||
------- | ||
fig : instance of Figure | ||
The resulting figure object for the heatmap plot. | ||
|
||
Notes | ||
----- | ||
.. versionadded:: 1.6 | ||
""" | ||
from mne import BaseEpochs | ||
scott-huberty marked this conversation as resolved.
Show resolved
Hide resolved
|
||
from mne._fiff.pick import _picks_to_idx | ||
|
||
_validate_type(epochs, BaseEpochs, "epochs") | ||
drammock marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_validate_type(alpha, "numeric", "alpha") | ||
_validate_type(sigma, ("numeric", None), "sigma") | ||
width = _ensure_int(width, "width") | ||
height = _ensure_int(height, "height") | ||
|
||
pos_picks = _picks_to_idx(epochs.info, "eyegaze") | ||
gaze_data = epochs.get_data(picks=pos_picks) | ||
gaze_ch_loc = np.array([epochs.info["chs"][idx]["loc"] for idx in pos_picks]) | ||
x_data = gaze_data[:, np.where(gaze_ch_loc[:, 4] == -1)[0], :] | ||
y_data = gaze_data[:, np.where(gaze_ch_loc[:, 4] == 1)[0], :] | ||
|
||
if x_data.shape[1] > 1: # binocular recording. Average across eyes | ||
logger.info("Detected binocular recording. Averaging positions across eyes.") | ||
x_data = np.nanmean(x_data, axis=1) # shape (n_epochs, n_samples) | ||
y_data = np.nanmean(y_data, axis=1) | ||
canvas = np.vstack((x_data.flatten(), y_data.flatten())) # shape (2, n_samples) | ||
|
||
# Create 2D histogram | ||
# Bin into image-like format | ||
hist, _, _ = np.histogram2d( | ||
canvas[1, :], | ||
canvas[0, :], | ||
bins=(height, width), | ||
range=[[0, height], [0, width]], | ||
) | ||
# Convert density from samples to seconds | ||
hist /= epochs.info["sfreq"] | ||
# Smooth the heatmap | ||
if sigma: | ||
hist = gaussian_filter(hist, sigma=sigma) | ||
|
||
return _plot_heatmap_array( | ||
hist, | ||
width=width, | ||
height=height, | ||
cmap=cmap, | ||
alpha=alpha, | ||
vmin=vlim[0], | ||
vmax=vlim[1], | ||
axes=axes, | ||
show=show, | ||
) | ||
|
||
|
||
def _plot_heatmap_array( | ||
data, | ||
width, | ||
height, | ||
cmap=None, | ||
alpha=None, | ||
vmin=None, | ||
vmax=None, | ||
axes=None, | ||
show=True, | ||
): | ||
"""Plot a heatmap of eyetracking gaze data from a numpy array.""" | ||
import matplotlib.pyplot as plt | ||
|
||
# Prepare axes | ||
if axes is not None: | ||
from matplotlib.axes import Axes | ||
|
||
_validate_type(axes, Axes, "axes") | ||
ax = axes | ||
fig = ax.get_figure() | ||
else: | ||
fig, ax = plt.subplots(constrained_layout=True) | ||
|
||
ax.set_title("Gaze heatmap") | ||
ax.set_xlabel("X position") | ||
ax.set_ylabel("Y position") | ||
|
||
# Prepare the heatmap | ||
alphas = 1 if alpha is None else alpha | ||
vmin = np.nanmin(data) if vmin is None else vmin | ||
vmax = np.nanmax(data) if vmax is None else vmax | ||
extent = [0, width, height, 0] # origin is the top left of the screen | ||
|
||
# Plot heatmap | ||
im = ax.imshow( | ||
data, | ||
aspect="equal", | ||
cmap=cmap, | ||
alpha=alphas, | ||
extent=extent, | ||
origin="upper", | ||
vmin=vmin, | ||
vmax=vmax, | ||
) | ||
|
||
# Prepare the colorbar | ||
fig.colorbar(im, ax=ax, shrink=0.6, label="Dwell time (seconds)") | ||
plt_show(show) | ||
return fig |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
# Authors: Scott Huberty <seh33@uw.edu> | ||
# | ||
# License: Simplified BSD | ||
|
||
import matplotlib.pyplot as plt | ||
import numpy as np | ||
import pytest | ||
|
||
import mne | ||
|
||
|
||
@pytest.mark.parametrize("axes", [None, True]) | ||
def test_plot_heatmap(axes): | ||
"""Test plot_gaze.""" | ||
# Create a toy epochs instance | ||
info = info = mne.create_info( | ||
ch_names=["xpos", "ypos"], sfreq=100, ch_types="eyegaze" | ||
) | ||
# simulate a steady fixation at the center of the screen | ||
width, height = (1920, 1080) | ||
shape = (1, 100) # x or y, time | ||
data = np.vstack([np.full(shape, width / 2), np.full(shape, height / 2)]) | ||
epochs = mne.EpochsArray(data[None, ...], info) | ||
epochs.info["chs"][0]["loc"][4] = -1 | ||
epochs.info["chs"][1]["loc"][4] = 1 | ||
|
||
if axes: | ||
axes = plt.subplot() | ||
fig = mne.viz.eyetracking.plot_gaze( | ||
epochs, width=width, height=height, axes=axes, cmap="Greys", sigma=None | ||
) | ||
img = fig.axes[0].images[0].get_array() | ||
# We simulated a 2D histogram where only the central pixel (960, 540) was active | ||
assert img.T[width // 2, height // 2] == 1 # central pixel is active | ||
assert np.sum(img) == 1 # only the central pixel should be active |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.