initial commit
This commit is contained in:
10
mne/preprocessing/eyetracking/__init__.py
Normal file
10
mne/preprocessing/eyetracking/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""Eye tracking specific preprocessing functions."""
|
||||
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
from .eyetracking import set_channel_types_eyetrack, convert_units
|
||||
from .calibration import Calibration, read_eyelink_calibration
|
||||
from ._pupillometry import interpolate_blinks
|
||||
from .utils import get_screen_visual_angle
|
||||
121
mne/preprocessing/eyetracking/_pupillometry.py
Normal file
121
mne/preprocessing/eyetracking/_pupillometry.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..._fiff.constants import FIFF
|
||||
from ...annotations import _annotations_starts_stops
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _check_preload, _validate_type, logger, warn
|
||||
|
||||
|
||||
def interpolate_blinks(raw, buffer=0.05, match="BAD_blink", interpolate_gaze=False):
|
||||
"""Interpolate eyetracking signals during blinks.
|
||||
|
||||
This function uses the timing of blink annotations to estimate missing
|
||||
data. Missing values are then interpolated linearly. Operates in place.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw : instance of Raw
|
||||
The raw data with at least one ``'pupil'`` or ``'eyegaze'`` channel.
|
||||
buffer : float | array-like of float, shape ``(2,))``
|
||||
The time in seconds before and after a blink to consider invalid and
|
||||
include in the segment to be interpolated over. Default is ``0.05`` seconds
|
||||
(50 ms). If array-like, the first element is the time before the blink and the
|
||||
second element is the time after the blink to consider invalid, for example,
|
||||
``(0.025, .1)``.
|
||||
match : str | list of str
|
||||
The description of annotations to interpolate over. If a list, the data within
|
||||
all annotations that match any of the strings in the list will be interpolated
|
||||
over. If a ``match`` starts with ``'BAD_'``, that part will be removed from the
|
||||
annotation description after interpolation. Defaults to ``'BAD_blink'``.
|
||||
interpolate_gaze : bool
|
||||
If False, only apply interpolation to ``'pupil channels'``. If True, interpolate
|
||||
over ``'eyegaze'`` channels as well. Defaults to False, because eye position can
|
||||
change in unpredictable ways during blinks.
|
||||
|
||||
Returns
|
||||
-------
|
||||
self : instance of Raw
|
||||
Returns the modified instance.
|
||||
|
||||
Notes
|
||||
-----
|
||||
.. versionadded:: 1.5
|
||||
"""
|
||||
_check_preload(raw, "interpolate_blinks")
|
||||
_validate_type(raw, BaseRaw, "raw")
|
||||
_validate_type(buffer, (float, tuple, list, np.ndarray), "buffer")
|
||||
_validate_type(match, (str, tuple, list, np.ndarray), "match")
|
||||
|
||||
# determine the buffer around blinks to include in the interpolation
|
||||
buffer = np.array(buffer, dtype=float)
|
||||
if buffer.size == 1:
|
||||
buffer = np.array([buffer, buffer])
|
||||
|
||||
if isinstance(match, str):
|
||||
match = [match]
|
||||
|
||||
# get the blink annotations
|
||||
blink_annots = [annot for annot in raw.annotations if annot["description"] in match]
|
||||
if not blink_annots:
|
||||
warn(f"No annotations matching {match} found. Aborting.")
|
||||
return raw
|
||||
_interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze=interpolate_gaze)
|
||||
|
||||
# remove bad from the annotation description
|
||||
for desc in match:
|
||||
if desc.startswith("BAD_"):
|
||||
logger.info(f"Removing 'BAD_' from {desc}.")
|
||||
raw.annotations.rename({desc: desc.replace("BAD_", "")})
|
||||
return raw
|
||||
|
||||
|
||||
def _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze):
|
||||
"""Interpolate eyetracking signals during blinks in-place."""
|
||||
logger.info("Interpolating missing data during blinks...")
|
||||
pre_buffer, post_buffer = buffer
|
||||
# iterate over each eyetrack channel and interpolate the blinks
|
||||
interpolated_chs = []
|
||||
for ci, ch_info in enumerate(raw.info["chs"]):
|
||||
if interpolate_gaze: # interpolate over all eyetrack channels
|
||||
if ch_info["kind"] != FIFF.FIFFV_EYETRACK_CH:
|
||||
continue
|
||||
else: # interpolate over pupil channels only
|
||||
if ch_info["coil_type"] != FIFF.FIFFV_COIL_EYETRACK_PUPIL:
|
||||
continue
|
||||
# Create an empty boolean mask
|
||||
mask = np.zeros_like(raw.times, dtype=bool)
|
||||
starts, ends = _annotations_starts_stops(raw, "BAD_blink")
|
||||
starts = np.divide(starts, raw.info["sfreq"])
|
||||
ends = np.divide(ends, raw.info["sfreq"])
|
||||
for annot, start, end in zip(blink_annots, starts, ends):
|
||||
if "ch_names" not in annot or not annot["ch_names"]:
|
||||
msg = f"Blink annotation missing values for 'ch_names' key: {annot}"
|
||||
raise ValueError(msg)
|
||||
start -= pre_buffer
|
||||
end += post_buffer
|
||||
if ch_info["ch_name"] not in annot["ch_names"]:
|
||||
continue # skip if the channel is not in the blink annotation
|
||||
# Update the mask for times within the current blink period
|
||||
mask |= (raw.times >= start) & (raw.times <= end)
|
||||
blink_indices = np.where(mask)[0]
|
||||
non_blink_indices = np.where(~mask)[0]
|
||||
|
||||
# Linear interpolation
|
||||
interpolated_samples = np.interp(
|
||||
raw.times[blink_indices],
|
||||
raw.times[non_blink_indices],
|
||||
raw._data[ci, non_blink_indices],
|
||||
)
|
||||
# Replace the samples at the blink_indices with the interpolated values
|
||||
raw._data[ci, blink_indices] = interpolated_samples
|
||||
interpolated_chs.append(ch_info["ch_name"])
|
||||
if interpolated_chs:
|
||||
logger.info(
|
||||
f"Interpolated {len(interpolated_chs)} channels: {interpolated_chs}"
|
||||
)
|
||||
else:
|
||||
warn("No channels were interpolated.")
|
||||
222
mne/preprocessing/eyetracking/calibration.py
Normal file
222
mne/preprocessing/eyetracking/calibration.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Eyetracking Calibration(s) class constructor."""
|
||||
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...io.eyelink._utils import _parse_calibration
|
||||
from ...utils import _check_fname, _validate_type, fill_doc, logger
|
||||
from ...viz.utils import plt_show
|
||||
|
||||
|
||||
@fill_doc
|
||||
class Calibration(dict):
|
||||
"""Eye-tracking calibration info.
|
||||
|
||||
This data structure behaves like a dictionary. It contains information regarding a
|
||||
calibration that was conducted during an eye-tracking recording.
|
||||
|
||||
.. note::
|
||||
When possible, a Calibration instance should be created with a helper function,
|
||||
such as :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
onset : float
|
||||
The onset of the calibration in seconds. If the calibration was
|
||||
performed before the recording started, the the onset can be
|
||||
negative.
|
||||
model : str
|
||||
A string, which is the model of the eye-tracking calibration that was applied.
|
||||
For example ``'H3'`` for a horizontal only 3-point calibration, or ``'HV3'``
|
||||
for a horizontal and vertical 3-point calibration.
|
||||
eye : str
|
||||
The eye that was calibrated. For example, ``'left'``, or ``'right'``.
|
||||
avg_error : float
|
||||
The average error in degrees between the calibration positions and the
|
||||
actual gaze position.
|
||||
max_error : float
|
||||
The maximum error in degrees that occurred between the calibration
|
||||
positions and the actual gaze position.
|
||||
positions : array-like of float, shape ``(n_calibration_points, 2)``
|
||||
The x and y coordinates of the calibration points.
|
||||
offsets : array-like of float, shape ``(n_calibration_points,)``
|
||||
The error in degrees between the calibration position and the actual
|
||||
gaze position for each calibration point.
|
||||
gaze : array-like of float, shape ``(n_calibration_points, 2)``
|
||||
The x and y coordinates of the actual gaze position for each calibration point.
|
||||
screen_size : array-like of shape ``(2,)``
|
||||
The width and height (in meters) of the screen that the eyetracking
|
||||
data was collected with. For example ``(.531, .298)`` for a monitor with
|
||||
a display area of 531 x 298 mm.
|
||||
screen_distance : float
|
||||
The distance (in meters) from the participant's eyes to the screen.
|
||||
screen_resolution : array-like of shape ``(2,)``
|
||||
The resolution (in pixels) of the screen that the eyetracking data
|
||||
was collected with. For example, ``(1920, 1080)`` for a 1920x1080
|
||||
resolution display.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
onset,
|
||||
model,
|
||||
eye,
|
||||
avg_error,
|
||||
max_error,
|
||||
positions,
|
||||
offsets,
|
||||
gaze,
|
||||
screen_size=None,
|
||||
screen_distance=None,
|
||||
screen_resolution=None,
|
||||
):
|
||||
super().__init__(
|
||||
onset=onset,
|
||||
model=model,
|
||||
eye=eye,
|
||||
avg_error=avg_error,
|
||||
max_error=max_error,
|
||||
screen_size=screen_size,
|
||||
screen_distance=screen_distance,
|
||||
screen_resolution=screen_resolution,
|
||||
positions=positions,
|
||||
offsets=offsets,
|
||||
gaze=gaze,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""Return a summary of the Calibration object."""
|
||||
return (
|
||||
f"Calibration |\n"
|
||||
f" onset: {self['onset']} seconds\n"
|
||||
f" model: {self['model']}\n"
|
||||
f" eye: {self['eye']}\n"
|
||||
f" average error: {self['avg_error']} degrees\n"
|
||||
f" max error: {self['max_error']} degrees\n"
|
||||
f" screen size: {self['screen_size']} meters\n"
|
||||
f" screen distance: {self['screen_distance']} meters\n"
|
||||
f" screen resolution: {self['screen_resolution']} pixels\n"
|
||||
)
|
||||
|
||||
def copy(self):
|
||||
"""Copy the instance.
|
||||
|
||||
Returns
|
||||
-------
|
||||
cal : instance of Calibration
|
||||
The copied Calibration.
|
||||
"""
|
||||
return deepcopy(self)
|
||||
|
||||
def plot(self, show_offsets=True, axes=None, show=True):
|
||||
"""Visualize calibration.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
show_offsets : bool
|
||||
Whether to display the offset (in visual degrees) of each calibration
|
||||
point or not. Defaults to ``True``.
|
||||
axes : instance of matplotlib.axes.Axes | None
|
||||
Axes to draw the calibration positions to. If ``None`` (default), a new axes
|
||||
will be created.
|
||||
show : bool
|
||||
Whether to show the figure or not. Defaults to ``True``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
fig : instance of matplotlib.figure.Figure
|
||||
The resulting figure object for the calibration plot.
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
msg = "positions and gaze keys must both be 2D numpy arrays."
|
||||
assert isinstance(self["positions"], np.ndarray), msg
|
||||
assert isinstance(self["gaze"], np.ndarray), msg
|
||||
|
||||
if axes is not None:
|
||||
from matplotlib.axes import Axes
|
||||
|
||||
_validate_type(axes, Axes, "axes")
|
||||
ax = axes
|
||||
fig = ax.get_figure()
|
||||
else: # create new figure and axes
|
||||
fig, ax = plt.subplots(layout="constrained")
|
||||
px, py = self["positions"].T
|
||||
gaze_x, gaze_y = self["gaze"].T
|
||||
|
||||
ax.set_title(f"Calibration ({self['eye']} eye)")
|
||||
ax.set_xlabel("x (pixels)")
|
||||
ax.set_ylabel("y (pixels)")
|
||||
|
||||
# Display avg_error and max_error in the top left corner
|
||||
text = (
|
||||
f"avg_error: {self['avg_error']} deg.\nmax_error: {self['max_error']} deg."
|
||||
)
|
||||
ax.text(
|
||||
0,
|
||||
1.01,
|
||||
text,
|
||||
transform=ax.transAxes,
|
||||
verticalalignment="baseline",
|
||||
fontsize=8,
|
||||
)
|
||||
|
||||
# Invert y-axis because the origin is in the top left corner
|
||||
ax.invert_yaxis()
|
||||
ax.scatter(px, py, color="gray")
|
||||
ax.scatter(gaze_x, gaze_y, color="red", alpha=0.5)
|
||||
|
||||
if show_offsets:
|
||||
for i in range(len(px)):
|
||||
x_offset = 0.01 * gaze_x[i] # 1% to the right of the gazepoint
|
||||
text = ax.text(
|
||||
x=gaze_x[i] + x_offset,
|
||||
y=gaze_y[i],
|
||||
s=self["offsets"][i],
|
||||
fontsize=8,
|
||||
ha="left",
|
||||
va="center",
|
||||
)
|
||||
|
||||
plt_show(show)
|
||||
return fig
|
||||
|
||||
|
||||
@fill_doc
|
||||
def read_eyelink_calibration(
|
||||
fname, screen_size=None, screen_distance=None, screen_resolution=None
|
||||
):
|
||||
"""Return info on calibrations collected in an eyelink file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fname : path-like
|
||||
Path to the eyelink file (.asc).
|
||||
screen_size : array-like of shape ``(2,)``
|
||||
The width and height (in meters) of the screen that the eyetracking
|
||||
data was collected with. For example ``(.531, .298)`` for a monitor with
|
||||
a display area of 531 x 298 mm. Defaults to ``None``.
|
||||
screen_distance : float
|
||||
The distance (in meters) from the participant's eyes to the screen.
|
||||
Defaults to ``None``.
|
||||
screen_resolution : array-like of shape ``(2,)``
|
||||
The resolution (in pixels) of the screen that the eyetracking data
|
||||
was collected with. For example, ``(1920, 1080)`` for a 1920x1080
|
||||
resolution display. Defaults to ``None``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
calibrations : list
|
||||
A list of :class:`~mne.preprocessing.eyetracking.Calibration` instances, one for
|
||||
each eye of every calibration that was performed during the recording session.
|
||||
"""
|
||||
fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname")
|
||||
logger.info(f"Reading calibration data from {fname}")
|
||||
lines = fname.read_text(encoding="ASCII").splitlines()
|
||||
return _parse_calibration(lines, screen_size, screen_distance, screen_resolution)
|
||||
327
mne/preprocessing/eyetracking/eyetracking.py
Normal file
327
mne/preprocessing/eyetracking/eyetracking.py
Normal file
@@ -0,0 +1,327 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..._fiff.constants import FIFF
|
||||
from ...epochs import BaseEpochs
|
||||
from ...evoked import Evoked
|
||||
from ...io import BaseRaw
|
||||
from ...utils import _check_option, _validate_type, logger, warn
|
||||
from .calibration import Calibration
|
||||
from .utils import _check_calibration
|
||||
|
||||
|
||||
# specific function to set eyetrack channels
|
||||
def set_channel_types_eyetrack(inst, mapping):
|
||||
"""Define sensor type for eyetrack channels.
|
||||
|
||||
This function can set all eye tracking specific information:
|
||||
channel type, unit, eye (and x/y component; only for gaze channels)
|
||||
|
||||
Supported channel types:
|
||||
``'eyegaze'`` and ``'pupil'``
|
||||
|
||||
Supported units:
|
||||
``'au'``, ``'px'``, ``'deg'``, ``'rad'`` (for eyegaze)
|
||||
``'au'``, ``'mm'``, ``'m'`` (for pupil)
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inst : instance of Raw, Epochs, or Evoked
|
||||
The data instance.
|
||||
mapping : dict
|
||||
A dictionary mapping a channel to a list/tuple including
|
||||
channel type, unit, eye, [and x/y component] (all as str), e.g.,
|
||||
``{'l_x': ('eyegaze', 'deg', 'left', 'x')}`` or
|
||||
``{'r_pupil': ('pupil', 'au', 'right')}``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
inst : instance of Raw | Epochs | Evoked
|
||||
The instance, modified in place.
|
||||
|
||||
Notes
|
||||
-----
|
||||
``inst.set_channel_types()`` to ``'eyegaze'`` or ``'pupil'``
|
||||
works as well, but cannot correctly set unit, eye and x/y component.
|
||||
|
||||
Data will be stored in SI units:
|
||||
if your data comes in ``deg`` (visual angle) it will be converted to
|
||||
``rad``, if it is in ``mm`` it will be converted to ``m``.
|
||||
"""
|
||||
ch_names = inst.info["ch_names"]
|
||||
|
||||
# allowed
|
||||
valid_types = ["eyegaze", "pupil"] # ch_type
|
||||
valid_units = {
|
||||
"px": ["px", "pixel"],
|
||||
"rad": ["rad", "radian", "radians"],
|
||||
"deg": ["deg", "degree", "degrees"],
|
||||
"m": ["m", "meter", "meters"],
|
||||
"mm": ["mm", "millimeter", "millimeters"],
|
||||
"au": [None, "none", "au", "arbitrary"],
|
||||
}
|
||||
valid_units["all"] = [item for sublist in valid_units.values() for item in sublist]
|
||||
valid_eye = {"l": ["left", "l"], "r": ["right", "r"]}
|
||||
valid_eye["all"] = [item for sublist in valid_eye.values() for item in sublist]
|
||||
valid_xy = {"x": ["x", "h", "horizontal"], "y": ["y", "v", "vertical"]}
|
||||
valid_xy["all"] = [item for sublist in valid_xy.values() for item in sublist]
|
||||
|
||||
# loop over channels
|
||||
for ch_name, ch_desc in mapping.items():
|
||||
if ch_name not in ch_names:
|
||||
raise ValueError(f"This channel name ({ch_name}) doesn't exist in info.")
|
||||
c_ind = ch_names.index(ch_name)
|
||||
|
||||
# set ch_type and unit
|
||||
ch_type = ch_desc[0].lower()
|
||||
if ch_type not in valid_types:
|
||||
raise ValueError(
|
||||
f"ch_type must be one of {valid_types}. Got '{ch_type}' instead."
|
||||
)
|
||||
if ch_type == "eyegaze":
|
||||
coil_type = FIFF.FIFFV_COIL_EYETRACK_POS
|
||||
elif ch_type == "pupil":
|
||||
coil_type = FIFF.FIFFV_COIL_EYETRACK_PUPIL
|
||||
inst.info["chs"][c_ind]["coil_type"] = coil_type
|
||||
inst.info["chs"][c_ind]["kind"] = FIFF.FIFFV_EYETRACK_CH
|
||||
|
||||
ch_unit = None if (ch_desc[1] is None) else ch_desc[1].lower()
|
||||
if ch_unit not in valid_units["all"]:
|
||||
raise ValueError(
|
||||
"unit must be one of {}. Got '{}' instead.".format(
|
||||
valid_units["all"], ch_unit
|
||||
)
|
||||
)
|
||||
if ch_unit in valid_units["px"]:
|
||||
unit_new = FIFF.FIFF_UNIT_PX
|
||||
elif ch_unit in valid_units["rad"]:
|
||||
unit_new = FIFF.FIFF_UNIT_RAD
|
||||
elif ch_unit in valid_units["deg"]: # convert deg to rad (SI)
|
||||
inst = inst.apply_function(_convert_deg_to_rad, picks=ch_name)
|
||||
unit_new = FIFF.FIFF_UNIT_RAD
|
||||
elif ch_unit in valid_units["m"]:
|
||||
unit_new = FIFF.FIFF_UNIT_M
|
||||
elif ch_unit in valid_units["mm"]: # convert mm to m (SI)
|
||||
inst = inst.apply_function(_convert_mm_to_m, picks=ch_name)
|
||||
unit_new = FIFF.FIFF_UNIT_M
|
||||
elif ch_unit in valid_units["au"]:
|
||||
unit_new = FIFF.FIFF_UNIT_NONE
|
||||
inst.info["chs"][c_ind]["unit"] = unit_new
|
||||
|
||||
# set eye (and x/y-component)
|
||||
loc = np.array(
|
||||
[
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
np.nan,
|
||||
]
|
||||
)
|
||||
|
||||
ch_eye = ch_desc[2].lower()
|
||||
if ch_eye not in valid_eye["all"]:
|
||||
raise ValueError(
|
||||
"eye must be one of {}. Got '{}' instead.".format(
|
||||
valid_eye["all"], ch_eye
|
||||
)
|
||||
)
|
||||
if ch_eye in valid_eye["l"]:
|
||||
loc[3] = -1
|
||||
elif ch_eye in valid_eye["r"]:
|
||||
loc[3] = 1
|
||||
|
||||
if ch_type == "eyegaze":
|
||||
ch_xy = ch_desc[3].lower()
|
||||
if ch_xy not in valid_xy["all"]:
|
||||
raise ValueError(
|
||||
"x/y must be one of {}. Got '{}' instead.".format(
|
||||
valid_xy["all"], ch_xy
|
||||
)
|
||||
)
|
||||
if ch_xy in valid_xy["x"]:
|
||||
loc[4] = -1
|
||||
elif ch_xy in valid_xy["y"]:
|
||||
loc[4] = 1
|
||||
|
||||
inst.info["chs"][c_ind]["loc"] = loc
|
||||
|
||||
return inst
|
||||
|
||||
|
||||
def _convert_mm_to_m(array):
|
||||
return array * 0.001
|
||||
|
||||
|
||||
def _convert_deg_to_rad(array):
|
||||
return array * np.pi / 180.0
|
||||
|
||||
|
||||
def convert_units(inst, calibration, to="radians"):
|
||||
"""Convert Eyegaze data from pixels to radians of visual angle or vice versa.
|
||||
|
||||
.. warning::
|
||||
Currently, depending on the units (pixels or radians), eyegaze channels may not
|
||||
be reported correctly in visualization functions like :meth:`mne.io.Raw.plot`.
|
||||
They will be shown correctly in :func:`mne.viz.eyetracking.plot_gaze`.
|
||||
See :gh:`11879` for more information.
|
||||
|
||||
.. Important::
|
||||
There are important considerations to keep in mind when using this function,
|
||||
see the Notes section below.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
inst : instance of Raw, Epochs, or Evoked
|
||||
The Raw, Epochs, or Evoked instance with eyegaze channels.
|
||||
calibration : Calibration
|
||||
Instance of Calibration, containing information about the screen size
|
||||
(in meters), viewing distance (in meters), and the screen resolution
|
||||
(in pixels).
|
||||
to : str
|
||||
Must be either ``"radians"`` or ``"pixels"``, indicating the desired unit.
|
||||
|
||||
Returns
|
||||
-------
|
||||
inst : instance of Raw | Epochs | Evoked
|
||||
The Raw, Epochs, or Evoked instance, modified in place.
|
||||
|
||||
Notes
|
||||
-----
|
||||
There are at least two important considerations to keep in mind when using this
|
||||
function:
|
||||
|
||||
1. Converting between on-screen pixels and visual angle is not a linear
|
||||
transformation. If the visual angle subtends less than approximately ``.44``
|
||||
radians (``25`` degrees), the conversion could be considered to be approximately
|
||||
linear. However, as the visual angle increases, the conversion becomes
|
||||
increasingly non-linear. This may lead to unexpected results after converting
|
||||
between pixels and visual angle.
|
||||
|
||||
* This function assumes that the head is fixed in place and aligned with the center
|
||||
of the screen, such that gaze to the center of the screen results in a visual
|
||||
angle of ``0`` radians.
|
||||
|
||||
.. versionadded:: 1.7
|
||||
"""
|
||||
_validate_type(inst, (BaseRaw, BaseEpochs, Evoked), "inst")
|
||||
_validate_type(calibration, Calibration, "calibration")
|
||||
_check_option("to", to, ("radians", "pixels"))
|
||||
_check_calibration(calibration)
|
||||
|
||||
# get screen parameters
|
||||
screen_size = calibration["screen_size"]
|
||||
screen_resolution = calibration["screen_resolution"]
|
||||
dist = calibration["screen_distance"]
|
||||
|
||||
# loop through channels and convert units
|
||||
converted_chs = []
|
||||
for ch_dict in inst.info["chs"]:
|
||||
if ch_dict["coil_type"] != FIFF.FIFFV_COIL_EYETRACK_POS:
|
||||
continue
|
||||
unit = ch_dict["unit"]
|
||||
name = ch_dict["ch_name"]
|
||||
|
||||
if ch_dict["loc"][4] == -1: # x-coordinate
|
||||
size = screen_size[0]
|
||||
res = screen_resolution[0]
|
||||
elif ch_dict["loc"][4] == 1: # y-coordinate
|
||||
size = screen_size[1]
|
||||
res = screen_resolution[1]
|
||||
else:
|
||||
raise ValueError(
|
||||
f"loc array not set properly for channel '{name}'. Index 4 should"
|
||||
f" be -1 or 1, but got {ch_dict['loc'][4]}"
|
||||
)
|
||||
# check unit, convert, and set new unit
|
||||
if to == "radians":
|
||||
if unit != FIFF.FIFF_UNIT_PX:
|
||||
raise ValueError(
|
||||
f"Data must be in pixels in order to convert to radians."
|
||||
f" Got {unit} for {name}"
|
||||
)
|
||||
inst.apply_function(_pix_to_rad, picks=name, size=size, res=res, dist=dist)
|
||||
ch_dict["unit"] = FIFF.FIFF_UNIT_RAD
|
||||
elif to == "pixels":
|
||||
if unit != FIFF.FIFF_UNIT_RAD:
|
||||
raise ValueError(
|
||||
f"Data must be in radians in order to convert to pixels."
|
||||
f" Got {unit} for {name}"
|
||||
)
|
||||
inst.apply_function(_rad_to_pix, picks=name, size=size, res=res, dist=dist)
|
||||
ch_dict["unit"] = FIFF.FIFF_UNIT_PX
|
||||
converted_chs.append(name)
|
||||
if converted_chs:
|
||||
logger.info(f"Converted {converted_chs} to {to}.")
|
||||
if to == "radians":
|
||||
# check if any values are greaater than .44 radians
|
||||
# (25 degrees) and warn user
|
||||
data = inst.get_data(picks=converted_chs)
|
||||
if np.any(np.abs(data) > 0.52):
|
||||
warn(
|
||||
"Some visual angle values subtend greater than .52 radians "
|
||||
"(30 degrees), meaning that the conversion between pixels "
|
||||
"and visual angle may be very non-linear. Take caution when "
|
||||
"interpreting these values. Max visual angle value in data:"
|
||||
f" {np.nanmax(data):0.2f} radians.",
|
||||
UserWarning,
|
||||
)
|
||||
else:
|
||||
warn("Could not find any eyegaze channels. Doing nothing.", UserWarning)
|
||||
return inst
|
||||
|
||||
|
||||
def _pix_to_rad(data, size, res, dist):
|
||||
"""Convert pixel coordinates to radians of visual angle.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : array-like, shape (n_samples,)
|
||||
A vector of pixel coordinates.
|
||||
size : float
|
||||
The width or height of the screen, in meters.
|
||||
res : int
|
||||
The screen resolution in pixels, along the x or y axis.
|
||||
dist : float
|
||||
The viewing distance from the screen, in meters.
|
||||
|
||||
Returns
|
||||
-------
|
||||
rad : ndarray, shape (n_samples)
|
||||
the data in radians.
|
||||
"""
|
||||
# Center the data so that 0 radians will be the center of the screen
|
||||
data -= res / 2
|
||||
# How many meters is the pixel width or height
|
||||
px_size = size / res
|
||||
# Convert to radians
|
||||
return np.arctan((data * px_size) / dist)
|
||||
|
||||
|
||||
def _rad_to_pix(data, size, res, dist):
|
||||
"""Convert radians of visual angle to pixel coordinates.
|
||||
|
||||
See the parameters section of _pix_to_rad for more information.
|
||||
|
||||
Returns
|
||||
-------
|
||||
pix : ndarray, shape (n_samples)
|
||||
the data in pixels.
|
||||
"""
|
||||
# How many meters is the pixel width or height
|
||||
px_size = size / res
|
||||
# 1. calculate length of opposite side of triangle (in meters)
|
||||
# 2. convert meters to pixel coordinates
|
||||
# 3. add half of screen resolution to uncenter the pixel data (0,0 is top left)
|
||||
return np.tan(data) * dist / px_size + res / 2
|
||||
45
mne/preprocessing/eyetracking/utils.py
Normal file
45
mne/preprocessing/eyetracking/utils.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Authors: The MNE-Python contributors.
|
||||
# License: BSD-3-Clause
|
||||
# Copyright the MNE-Python contributors.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ...utils import _validate_type
|
||||
from .calibration import Calibration
|
||||
|
||||
|
||||
def _check_calibration(
|
||||
calibration, want_keys=("screen_size", "screen_resolution", "screen_distance")
|
||||
):
|
||||
missing_keys = []
|
||||
for key in want_keys:
|
||||
if calibration.get(key, None) is None:
|
||||
missing_keys.append(key)
|
||||
|
||||
if missing_keys:
|
||||
raise KeyError(
|
||||
"Calibration object must have the following keys with valid values:"
|
||||
f" {', '.join(missing_keys)}"
|
||||
)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def get_screen_visual_angle(calibration):
|
||||
"""Calculate the radians of visual angle that the participant screen subtends.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
calibration : Calibration
|
||||
An instance of Calibration. Must have valid values for ``"screen_size"`` and
|
||||
``"screen_distance"`` keys.
|
||||
|
||||
Returns
|
||||
-------
|
||||
visual angle in radians : ndarray, shape (2,)
|
||||
The visual angle of the monitor width and height, respectively.
|
||||
"""
|
||||
_validate_type(calibration, Calibration, "calibration")
|
||||
_check_calibration(calibration, want_keys=("screen_size", "screen_distance"))
|
||||
size = np.array(calibration["screen_size"])
|
||||
return 2 * np.arctan(size / (2 * calibration["screen_distance"]))
|
||||
Reference in New Issue
Block a user