initial commit
This commit is contained in:
7
mne/viz/eyetracking/__init__.py
Normal file
7
mne/viz/eyetracking/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Eye-tracking visualization routines."""
|
||||
#
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
from .heatmap import plot_gaze
|
||||
209
mne/viz/eyetracking/heatmap.py
Normal file
209
mne/viz/eyetracking/heatmap.py
Normal file
@@ -0,0 +1,209 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from ..._fiff.constants import FIFF
|
||||
from ...utils import _validate_type, fill_doc, logger
|
||||
from ..utils import plt_show
|
||||
|
||||
|
||||
@fill_doc
|
||||
def plot_gaze(
|
||||
epochs,
|
||||
*,
|
||||
calibration=None,
|
||||
width=None,
|
||||
height=None,
|
||||
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.
|
||||
calibration : instance of Calibration | None
|
||||
An instance of Calibration with information about the screen size, distance,
|
||||
and resolution. If ``None``, you must provide a width and height.
|
||||
width : int
|
||||
The width dimension of the plot canvas, only valid if eyegaze data are in
|
||||
pixels. For example, if the participant screen resolution was 1920x1080, then
|
||||
the width should be 1920.
|
||||
height : int
|
||||
The height dimension of the plot canvas, only valid if eyegaze data are in
|
||||
pixels. For example, if 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
|
||||
from mne._fiff.pick import _picks_to_idx
|
||||
|
||||
from ...preprocessing.eyetracking.utils import (
|
||||
_check_calibration,
|
||||
get_screen_visual_angle,
|
||||
)
|
||||
|
||||
_validate_type(epochs, BaseEpochs, "epochs")
|
||||
_validate_type(alpha, "numeric", "alpha")
|
||||
_validate_type(sigma, ("numeric", None), "sigma")
|
||||
|
||||
# Get the gaze data
|
||||
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], :]
|
||||
unit = epochs.info["chs"][pos_picks[0]]["unit"] # assumes all units are the same
|
||||
|
||||
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)
|
||||
|
||||
# Check that we have the right inputs
|
||||
if calibration is not None:
|
||||
if width is not None or height is not None:
|
||||
raise ValueError(
|
||||
"If a calibration is provided, you cannot provide a width or height"
|
||||
" to plot heatmaps. Please provide only the calibration object."
|
||||
)
|
||||
_check_calibration(calibration)
|
||||
if unit == FIFF.FIFF_UNIT_PX:
|
||||
width, height = calibration["screen_resolution"]
|
||||
elif unit == FIFF.FIFF_UNIT_RAD:
|
||||
width, height = calibration["screen_size"]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid unit type: {unit}. gaze data Must be pixels or radians."
|
||||
)
|
||||
else:
|
||||
if width is None or height is None:
|
||||
raise ValueError(
|
||||
"If no calibration is provided, you must provide a width and height"
|
||||
" to plot heatmaps."
|
||||
)
|
||||
|
||||
# Create 2D histogram
|
||||
# We need to set the histogram bins & bounds, and imshow extent, based on the units
|
||||
if unit == FIFF.FIFF_UNIT_PX: # pixel on screen
|
||||
_range = [[0, height], [0, width]]
|
||||
bins_x, bins_y = width, height
|
||||
extent = [0, width, height, 0]
|
||||
elif unit == FIFF.FIFF_UNIT_RAD: # radians of visual angle
|
||||
if not calibration:
|
||||
raise ValueError(
|
||||
"If gaze data are in Radians, you must provide a"
|
||||
" calibration instance to plot heatmaps."
|
||||
)
|
||||
width, height = get_screen_visual_angle(calibration)
|
||||
x_range = [-width / 2, width / 2]
|
||||
y_range = [-height / 2, height / 2]
|
||||
_range = [y_range, x_range]
|
||||
extent = (x_range[0], x_range[1], y_range[0], y_range[1])
|
||||
bins_x, bins_y = calibration["screen_resolution"]
|
||||
|
||||
hist, _, _ = np.histogram2d(
|
||||
canvas[1, :],
|
||||
canvas[0, :],
|
||||
bins=(bins_y, bins_x),
|
||||
range=_range,
|
||||
)
|
||||
# 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],
|
||||
extent=extent,
|
||||
axes=axes,
|
||||
show=show,
|
||||
)
|
||||
|
||||
|
||||
def _plot_heatmap_array(
|
||||
data,
|
||||
width,
|
||||
height,
|
||||
*,
|
||||
cmap=None,
|
||||
alpha=None,
|
||||
vmin=None,
|
||||
vmax=None,
|
||||
extent=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
|
||||
if extent is None:
|
||||
extent = [0, width, height, 0]
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user