diff --git a/doc/changes/devel/12846.bugfix.rst b/doc/changes/devel/12846.bugfix.rst new file mode 100644 index 00000000000..2b40e77490e --- /dev/null +++ b/doc/changes/devel/12846.bugfix.rst @@ -0,0 +1 @@ +Enforce SI units for Eyetracking data (eyegaze data should be radians of visual angle, not pixels. Pupil size data should be meters). Updated tutorials so demonstrate how to convert data to SI units before analyses (:gh:`12846`` by `Scott Huberty`_) \ No newline at end of file diff --git a/examples/visualization/eyetracking_plot_heatmap.py b/examples/visualization/eyetracking_plot_heatmap.py index a57857f34ad..07983685b5e 100644 --- a/examples/visualization/eyetracking_plot_heatmap.py +++ b/examples/visualization/eyetracking_plot_heatmap.py @@ -68,6 +68,10 @@ cmap = plt.get_cmap("viridis") plot_gaze(epochs["natural"], calibration=calibration, cmap=cmap, sigma=50) +# %% +# .. note:: The (0, 0) pixel coordinates are at the top-left of the +# trackable area of the screen for many eye trackers. + # %% # Overlaying plots with images # ---------------------------- diff --git a/mne/defaults.py b/mne/defaults.py index a2dd2a05250..a2769d79e6f 100644 --- a/mne/defaults.py +++ b/mne/defaults.py @@ -64,8 +64,8 @@ whitened="Z", gsr="S", temperature="C", - eyegaze="AU", - pupil="AU", + eyegaze="rad", + pupil="M", ), units=dict( mag="fT", @@ -92,8 +92,8 @@ whitened="Z", gsr="S", temperature="C", - eyegaze="AU", - pupil="AU", + eyegaze="rad", + pupil="µM", ), # scalings for the units scalings=dict( @@ -122,7 +122,7 @@ gsr=1.0, temperature=1.0, eyegaze=1.0, - pupil=1.0, + pupil=1e6, ), # rough guess for a good plot scalings_plot_raw=dict( @@ -156,8 +156,8 @@ gof=1e2, gsr=1.0, temperature=0.1, - eyegaze=3e-1, - pupil=1e3, + eyegaze=2e-1, + pupil=10e-6, ), scalings_cov_rank=dict( mag=1e12, @@ -183,8 +183,8 @@ hbo=(0, 20), hbr=(0, 20), csd=(-50.0, 50.0), - eyegaze=(0.0, 5000.0), - pupil=(0.0, 5000.0), + eyegaze=(-1, 1), + pupil=(0.0, 20), ), titles=dict( mag="Magnetometers", diff --git a/tutorials/io/70_reading_eyetracking_data.py b/tutorials/io/70_reading_eyetracking_data.py index ce4fcf41d9b..3cf72719e4c 100644 --- a/tutorials/io/70_reading_eyetracking_data.py +++ b/tutorials/io/70_reading_eyetracking_data.py @@ -78,29 +78,43 @@ new line, the y-coordinate *increased*, which is why the ``ypos_right`` channel in the plot below increases over time (for example, at about 4-seconds, and at about 8-seconds). + +.. seealso:: + + :ref:`tut-eyetrack` """ # %% -from mne.datasets import misc -from mne.io import read_raw_eyelink +import mne # %% -fpath = misc.data_path() / "eyetracking" / "eyelink" -raw = read_raw_eyelink(fpath / "px_textpage_ws.asc", create_annotations=["blinks"]) -custom_scalings = dict(eyegaze=1e3) -raw.pick(picks="eyetrack").plot(scalings=custom_scalings) +fpath = mne.datasets.misc.data_path() / "eyetracking" / "eyelink" +fname = fpath / "px_textpage_ws.asc" +raw = mne.io.read_raw_eyelink(fname, create_annotations=["blinks"]) +cal = mne.preprocessing.eyetracking.read_eyelink_calibration( + fname, + screen_distance=0.7, + screen_size=(0.53, 0.3), + screen_resolution=(1920, 1080), +)[0] +mne.preprocessing.eyetracking.convert_units(raw, calibration=cal, to="radians") +# %% +# Visualizing the data +# ^^^^^^^^^^^^^^^^^^^^ # %% -# .. important:: The (0, 0) pixel coordinates are at the top-left of the -# trackable area of the screen. Gaze towards lower areas of the -# the screen will yield a relatively higher y-coordinate. -# -# Note that we passed a custom `dict` to the ``'scalings'`` argument of -# `mne.io.Raw.plot`. This is because MNE's default plot scalings for eye -# position data are calibrated for HREF data, which are stored in radians -# (read below). +cal.plot() +# %% +custom_scalings = dict(pupil=1e3) +raw.pick(picks="eyetrack").plot(scalings=custom_scalings) + +# %% +# Note that we passed a custom `dict` to the ``'scalings'`` argument of +# `mne.io.Raw.plot`. This is because MNE expects the data to be in SI units +# (radians for eyegaze data, and meters for pupil size data), but we did not convert +# the pupil size data in this example. # %% # Head-Referenced Eye Angle (HREF) @@ -124,9 +138,11 @@ # %% -fpath = misc.data_path() / "eyetracking" / "eyelink" -raw = read_raw_eyelink(fpath / "HREF_textpage_ws.asc", create_annotations=["blinks"]) -raw.pick(picks="eyetrack").plot() +fpath = mne.datasets.misc.data_path() / "eyetracking" / "eyelink" +fname_href = fpath / "HREF_textpage_ws.asc" +raw = mne.io.read_raw_eyelink(fname_href, create_annotations=["blinks"]) +custom_scalings = dict(pupil=1e3) +raw.pick(picks="eyetrack").plot(scalings=custom_scalings) # %% # Pupil Position diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index bad4eeeda67..85f5d80bf82 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -99,16 +99,32 @@ first_cal.plot() +# %% +# Standardizing eyetracking data to SI units +# ------------------------------------------ +# +# EyeLink stores eyegaze positions in pixels, and pupil size in arbitrary units. +# MNE-Python expects eyegaze positions to be in radians of visual angle, and pupil +# size to be in meters. We can convert the eyegaze positions to radians using +# :func:`~mne.preprocessing.eyetracking.convert_units`. We'll pass the calibration +# object we created above, after specifying the screen resolution, screen size, and +# screen distance. + +first_cal["screen_resolution"] = (1920, 1080) +first_cal["screen_size"] = (0.53, 0.3) +first_cal["screen_distance"] = 0.9 +mne.preprocessing.eyetracking.convert_units(raw_et, calibration=first_cal, to="radians") + # %% # Plot the raw eye-tracking data # ------------------------------ # -# Let's plot the raw eye-tracking data. We'll pass a custom `dict` into -# the scalings argument to make the eyegaze channel traces legible when plotting, -# since this file contains pixel position data (as opposed to eye angles, -# which are reported in radians). +# Let's plot the raw eye-tracking data. Since we did not convert the pupil size to +# meters, we'll pass a custom `dict` into the scalings argument to make the pupil size +# traces legible when plotting. -raw_et.plot(scalings=dict(eyegaze=1e3)) +ps_scalings = dict(pupil=1e3) +raw_et.plot(scalings=ps_scalings) # %% # Handling blink artifacts @@ -189,7 +205,13 @@ picks_idx = mne.pick_channels( raw_et.ch_names, frontal + occipital + pupil, ordered=True ) -raw_et.plot(events=et_events, event_id=event_dict, event_color="g", order=picks_idx) +raw_et.plot( + events=et_events, + event_id=event_dict, + event_color="g", + order=picks_idx, + scalings=ps_scalings, +) # %% @@ -203,14 +225,16 @@ raw_et, events=et_events, event_id=event_dict, tmin=-0.3, tmax=3, baseline=None ) del raw_et # free up some memory -epochs[:8].plot(events=et_events, event_id=event_dict, order=picks_idx) +epochs[:8].plot( + events=et_events, event_id=event_dict, order=picks_idx, scalings=ps_scalings +) # %% # For this experiment, the participant was instructed to fixate on a crosshair in the # center of the screen. Let's plot the gaze position data to confirm that the # participant primarily kept their gaze fixated at the center of the screen. -plot_gaze(epochs, width=1920, height=1080) +plot_gaze(epochs, calibration=first_cal) # %% # .. seealso:: :ref:`tut-eyetrack-heatmap`