328 lines
12 KiB
Python
328 lines
12 KiB
Python
# 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
|