# 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