Aluode/PerceptionLabPortable
0
1# Authors: The MNE-Python contributors.2# License: BSD-3-Clause3# Copyright the MNE-Python contributors.4 5 6import numpy as np7 8from ..._fiff.constants import FIFF9from ...epochs import BaseEpochs10from ...evoked import Evoked11from ...io import BaseRaw12from ...utils import _check_option, _validate_type, logger, warn13from .calibration import Calibration14from .utils import _check_calibration15 16 17# specific function to set eyetrack channels18def set_channel_types_eyetrack(inst, mapping):19 """Define sensor type for eyetrack channels.20 21 This function can set all eye tracking specific information:22 channel type, unit, eye (and x/y component; only for gaze channels)23 24 Supported channel types:25 ``'eyegaze'`` and ``'pupil'``26 27 Supported units:28 ``'au'``, ``'px'``, ``'deg'``, ``'rad'`` (for eyegaze)29 ``'au'``, ``'mm'``, ``'m'`` (for pupil)30 31 Parameters32 ----------33 inst : instance of Raw, Epochs, or Evoked34 The data instance.35 mapping : dict36 A dictionary mapping a channel to a list/tuple including37 channel type, unit, eye, [and x/y component] (all as str), e.g.,38 ``{'l_x': ('eyegaze', 'deg', 'left', 'x')}`` or39 ``{'r_pupil': ('pupil', 'au', 'right')}``.40 41 Returns42 -------43 inst : instance of Raw | Epochs | Evoked44 The instance, modified in place.45 46 Notes47 -----48 ``inst.set_channel_types()`` to ``'eyegaze'`` or ``'pupil'``49 works as well, but cannot correctly set unit, eye and x/y component.50 51 Data will be stored in SI units:52 if your data comes in ``deg`` (visual angle) it will be converted to53 ``rad``, if it is in ``mm`` it will be converted to ``m``.54 """55 ch_names = inst.info["ch_names"]56 57 # allowed58 valid_types = ["eyegaze", "pupil"] # ch_type59 valid_units = {60 "px": ["px", "pixel"],61 "rad": ["rad", "radian", "radians"],62 "deg": ["deg", "degree", "degrees"],63 "m": ["m", "meter", "meters"],64 "mm": ["mm", "millimeter", "millimeters"],65 "au": [None, "none", "au", "arbitrary"],66 }67 valid_units["all"] = [item for sublist in valid_units.values() for item in sublist]68 valid_eye = {"l": ["left", "l"], "r": ["right", "r"]}69 valid_eye["all"] = [item for sublist in valid_eye.values() for item in sublist]70 valid_xy = {"x": ["x", "h", "horizontal"], "y": ["y", "v", "vertical"]}71 valid_xy["all"] = [item for sublist in valid_xy.values() for item in sublist]72 73 # loop over channels74 for ch_name, ch_desc in mapping.items():75 if ch_name not in ch_names:76 raise ValueError(f"This channel name ({ch_name}) doesn't exist in info.")77 c_ind = ch_names.index(ch_name)78 79 # set ch_type and unit80 ch_type = ch_desc[0].lower()81 if ch_type not in valid_types:82 raise ValueError(83 f"ch_type must be one of {valid_types}. Got '{ch_type}' instead."84 )85 if ch_type == "eyegaze":86 coil_type = FIFF.FIFFV_COIL_EYETRACK_POS87 elif ch_type == "pupil":88 coil_type = FIFF.FIFFV_COIL_EYETRACK_PUPIL89 inst.info["chs"][c_ind]["coil_type"] = coil_type90 inst.info["chs"][c_ind]["kind"] = FIFF.FIFFV_EYETRACK_CH91 92 ch_unit = None if (ch_desc[1] is None) else ch_desc[1].lower()93 if ch_unit not in valid_units["all"]:94 raise ValueError(95 "unit must be one of {}. Got '{}' instead.".format(96 valid_units["all"], ch_unit97 )98 )99 if ch_unit in valid_units["px"]:100 unit_new = FIFF.FIFF_UNIT_PX101 elif ch_unit in valid_units["rad"]:102 unit_new = FIFF.FIFF_UNIT_RAD103 elif ch_unit in valid_units["deg"]: # convert deg to rad (SI)104 inst = inst.apply_function(_convert_deg_to_rad, picks=ch_name)105 unit_new = FIFF.FIFF_UNIT_RAD106 elif ch_unit in valid_units["m"]:107 unit_new = FIFF.FIFF_UNIT_M108 elif ch_unit in valid_units["mm"]: # convert mm to m (SI)109 inst = inst.apply_function(_convert_mm_to_m, picks=ch_name)110 unit_new = FIFF.FIFF_UNIT_M111 elif ch_unit in valid_units["au"]:112 unit_new = FIFF.FIFF_UNIT_NONE113 inst.info["chs"][c_ind]["unit"] = unit_new114 115 # set eye (and x/y-component)116 loc = np.array(117 [118 np.nan,119 np.nan,120 np.nan,121 np.nan,122 np.nan,123 np.nan,124 np.nan,125 np.nan,126 np.nan,127 np.nan,128 np.nan,129 np.nan,130 ]131 )132 133 ch_eye = ch_desc[2].lower()134 if ch_eye not in valid_eye["all"]:135 raise ValueError(136 "eye must be one of {}. Got '{}' instead.".format(137 valid_eye["all"], ch_eye138 )139 )140 if ch_eye in valid_eye["l"]:141 loc[3] = -1142 elif ch_eye in valid_eye["r"]:143 loc[3] = 1144 145 if ch_type == "eyegaze":146 ch_xy = ch_desc[3].lower()147 if ch_xy not in valid_xy["all"]:148 raise ValueError(149 "x/y must be one of {}. Got '{}' instead.".format(150 valid_xy["all"], ch_xy151 )152 )153 if ch_xy in valid_xy["x"]:154 loc[4] = -1155 elif ch_xy in valid_xy["y"]:156 loc[4] = 1157 158 inst.info["chs"][c_ind]["loc"] = loc159 160 return inst161 162 163def _convert_mm_to_m(array):164 return array * 0.001165 166 167def _convert_deg_to_rad(array):168 return array * np.pi / 180.0169 170 171def convert_units(inst, calibration, to="radians"):172 """Convert Eyegaze data from pixels to radians of visual angle or vice versa.173 174 .. warning::175 Currently, depending on the units (pixels or radians), eyegaze channels may not176 be reported correctly in visualization functions like :meth:`mne.io.Raw.plot`.177 They will be shown correctly in :func:`mne.viz.eyetracking.plot_gaze`.178 See :gh:`11879` for more information.179 180 .. Important::181 There are important considerations to keep in mind when using this function,182 see the Notes section below.183 184 Parameters185 ----------186 inst : instance of Raw, Epochs, or Evoked187 The Raw, Epochs, or Evoked instance with eyegaze channels.188 calibration : Calibration189 Instance of Calibration, containing information about the screen size190 (in meters), viewing distance (in meters), and the screen resolution191 (in pixels).192 to : str193 Must be either ``"radians"`` or ``"pixels"``, indicating the desired unit.194 195 Returns196 -------197 inst : instance of Raw | Epochs | Evoked198 The Raw, Epochs, or Evoked instance, modified in place.199 200 Notes201 -----202 There are at least two important considerations to keep in mind when using this203 function:204 205 1. Converting between on-screen pixels and visual angle is not a linear206 transformation. If the visual angle subtends less than approximately ``.44``207 radians (``25`` degrees), the conversion could be considered to be approximately208 linear. However, as the visual angle increases, the conversion becomes209 increasingly non-linear. This may lead to unexpected results after converting210 between pixels and visual angle.211 212 * This function assumes that the head is fixed in place and aligned with the center213 of the screen, such that gaze to the center of the screen results in a visual214 angle of ``0`` radians.215 216 .. versionadded:: 1.7217 """218 _validate_type(inst, (BaseRaw, BaseEpochs, Evoked), "inst")219 _validate_type(calibration, Calibration, "calibration")220 _check_option("to", to, ("radians", "pixels"))221 _check_calibration(calibration)222 223 # get screen parameters224 screen_size = calibration["screen_size"]225 screen_resolution = calibration["screen_resolution"]226 dist = calibration["screen_distance"]227 228 # loop through channels and convert units229 converted_chs = []230 for ch_dict in inst.info["chs"]:231 if ch_dict["coil_type"] != FIFF.FIFFV_COIL_EYETRACK_POS:232 continue233 unit = ch_dict["unit"]234 name = ch_dict["ch_name"]235 236 if ch_dict["loc"][4] == -1: # x-coordinate237 size = screen_size[0]238 res = screen_resolution[0]239 elif ch_dict["loc"][4] == 1: # y-coordinate240 size = screen_size[1]241 res = screen_resolution[1]242 else:243 raise ValueError(244 f"loc array not set properly for channel '{name}'. Index 4 should"245 f" be -1 or 1, but got {ch_dict['loc'][4]}"246 )247 # check unit, convert, and set new unit248 if to == "radians":249 if unit != FIFF.FIFF_UNIT_PX:250 raise ValueError(251 f"Data must be in pixels in order to convert to radians."252 f" Got {unit} for {name}"253 )254 inst.apply_function(_pix_to_rad, picks=name, size=size, res=res, dist=dist)255 ch_dict["unit"] = FIFF.FIFF_UNIT_RAD256 elif to == "pixels":257 if unit != FIFF.FIFF_UNIT_RAD:258 raise ValueError(259 f"Data must be in radians in order to convert to pixels."260 f" Got {unit} for {name}"261 )262 inst.apply_function(_rad_to_pix, picks=name, size=size, res=res, dist=dist)263 ch_dict["unit"] = FIFF.FIFF_UNIT_PX264 converted_chs.append(name)265 if converted_chs:266 logger.info(f"Converted {converted_chs} to {to}.")267 if to == "radians":268 # check if any values are greaater than .44 radians269 # (25 degrees) and warn user270 data = inst.get_data(picks=converted_chs)271 if np.any(np.abs(data) > 0.52):272 warn(273 "Some visual angle values subtend greater than .52 radians "274 "(30 degrees), meaning that the conversion between pixels "275 "and visual angle may be very non-linear. Take caution when "276 "interpreting these values. Max visual angle value in data:"277 f" {np.nanmax(data):0.2f} radians.",278 UserWarning,279 )280 else:281 warn("Could not find any eyegaze channels. Doing nothing.", UserWarning)282 return inst283 284 285def _pix_to_rad(data, size, res, dist):286 """Convert pixel coordinates to radians of visual angle.287 288 Parameters289 ----------290 data : array-like, shape (n_samples,)291 A vector of pixel coordinates.292 size : float293 The width or height of the screen, in meters.294 res : int295 The screen resolution in pixels, along the x or y axis.296 dist : float297 The viewing distance from the screen, in meters.298 299 Returns300 -------301 rad : ndarray, shape (n_samples)302 the data in radians.303 """304 # Center the data so that 0 radians will be the center of the screen305 data -= res / 2306 # How many meters is the pixel width or height307 px_size = size / res308 # Convert to radians309 return np.arctan((data * px_size) / dist)310 311 312def _rad_to_pix(data, size, res, dist):313 """Convert radians of visual angle to pixel coordinates.314 315 See the parameters section of _pix_to_rad for more information.316 317 Returns318 -------319 pix : ndarray, shape (n_samples)320 the data in pixels.321 """322 # How many meters is the pixel width or height323 px_size = size / res324 # 1. calculate length of opposite side of triangle (in meters)325 # 2. convert meters to pixel coordinates326 # 3. add half of screen resolution to uncenter the pixel data (0,0 is top left)327 return np.tan(data) * dist / px_size + res / 2328 