The Brain Imaging Data Structure (BIDS) is a standard way to organise and describe data. Eye-tracking data are stored as physiological recordings (BEP020). For each participant, task and run there is one recording per eye:
my_dataset/
dataset_description.json
sub-01/
eye/ (or beh/, func/)
sub-01_task-rest_recording-eye1_physio.tsv.gz samples, no header
sub-01_task-rest_recording-eye1_physio.json sidecar: columns, rate, eye
sub-01_task-rest_recording-eye1_physioevents.tsv.gz messages, blinks, ...
sub-01_task-rest_recording-eye1_physioevents.json
sub-01_task-rest_recording-eye2_physio.tsv.gz ... same for the other eye
*_physio.tsv.gz has no header. Its
column names are listed in the Columns field of the JSON
sidecar, usually timestamp, x_coordinate,
y_coordinate and pupil_size. Missing values
are n/a.SamplingFrequency,
RecordedEye ("left", "right" or
"cyclopean") and the units of each column.*_physioevents.tsv.gz lists events with an
onset, a duration (in seconds) and, for
example, a trial_type and a message. With
"OnsetSource": "timestamp" in its sidecar, onsets are on
the same clock as the samples’ timestamp.Tools such as eye2bids convert
EyeLink .edf files to this format.
read_bids_eyetrack() reads a BIDS dataset (or a set of
*_physio.tsv.gz files) into one data frame, one row per
sample, in the same layout as read_asc(). gazeR includes a
small example: the first 6 trials of participant PN010 from the
Black-and-White Switch task in the public OSF project Dynamics of the Pupillary Consensual
Response (binocular, 1000 Hz), written as BIDS from the
.asc file used in the ASC vignette.
library(gazer)
library(dplyr)
bids_dir <- system.file("extdata", "bids_eyetrack", package = "gazer")
list.files(bids_dir, recursive = TRUE)
#> [1] "dataset_description.json"
#> [2] "sub-PN010/eye/sub-PN010_task-bws_recording-eye1_physio.json"
#> [3] "sub-PN010/eye/sub-PN010_task-bws_recording-eye1_physio.tsv.gz"
#> [4] "sub-PN010/eye/sub-PN010_task-bws_recording-eye1_physioevents.json"
#> [5] "sub-PN010/eye/sub-PN010_task-bws_recording-eye1_physioevents.tsv.gz"
#> [6] "sub-PN010/eye/sub-PN010_task-bws_recording-eye2_physio.json"
#> [7] "sub-PN010/eye/sub-PN010_task-bws_recording-eye2_physio.tsv.gz"
#> [8] "sub-PN010/eye/sub-PN010_task-bws_recording-eye2_physioevents.json"
#> [9] "sub-PN010/eye/sub-PN010_task-bws_recording-eye2_physioevents.tsv.gz"Point read_bids_eyetrack() at the dataset folder. It
searches it for *_physio.tsv.gz files and reads each one
with its sidecar and events file:
pn010 <- read_bids_eyetrack(bids_dir)
pn010
#> # A tibble: 34,390 × 11
#> subject trial time pupil x y blink message tracker_time sample_rate
#> <chr> <int> <dbl> <dbl> <dbl> <dbl> <int> <chr> <dbl> <int>
#> 1 sub-PN0… 1 0 1776. 380. 332. 0 !CMD 0… 178884819 1000
#> 2 sub-PN0… 1 1 1776 380. 332. 0 <NA> 178884820 1000
#> 3 sub-PN0… 1 2 1776. 381. 332. 0 <NA> 178884821 1000
#> 4 sub-PN0… 1 3 1776. 380. 332. 0 <NA> 178884822 1000
#> 5 sub-PN0… 1 4 1775 380. 332. 0 <NA> 178884823 1000
#> 6 sub-PN0… 1 5 1774. 381. 332. 0 <NA> 178884824 1000
#> 7 sub-PN0… 1 6 1772. 381. 331. 0 <NA> 178884825 1000
#> 8 sub-PN0… 1 7 1776. 381. 331. 0 <NA> 178884826 1000
#> 9 sub-PN0… 1 8 1779 381 331. 0 <NA> 178884827 1000
#> 10 sub-PN0… 1 9 1780 381. 332. 0 <NA> 178884828 1000
#> # ℹ 34,380 more rows
#> # ℹ 1 more variable: eyes_recorded <chr>The columns are:
subject: built from the sub-,
ses-, task- and run- entities of
the file name, e.g. sub-PN010_task-bws. Each run of each
task is kept apart.trial: see Trials below.time: ms from the start of the trial. Timestamps
recorded in seconds are converted to ms.pupil, x, y: from
pupil_size, x_coordinate and
y_coordinate. Pupil values of 0 or less count as missing
(NA).blink: 1 during a blink, from
trial_type == "blink" (or a blink column equal
to 1) in the events file, for its duration.message: messages from the events file, each attached
to the first sample at or after its onset. Several messages on the same
sample are joined with "; ".tracker_time: the original timestamp.sample_rate: SamplingFrequency from the
sidecar.eyes_recorded: "both", or the single eye
recorded.Any other sample columns in the file are kept.
When a run has a recording for each eye, they are merged by timestamp
and eye works as in read_asc():
"average" (the default), "left",
"right", or "both".
With "average" and "both":
pupil (and x, y) is the
monocular mean of the two eyes
(compute_monocular_mean()), as EyeLink reports the average
eye: the mean of both eyes, or the value of the one valid eye. It is
NA only when neither eye is valid.blink is 1 if either eye is in a
blink."both" also keeps each eye’s columns
(pupil_left, pupil_right, x_left,
…, blink_left, blink_right):
read_bids_eyetrack(bids_dir, eye = "both") |>
select(time, pupil, pupil_left, pupil_right, blink_left, blink_right) |>
head()
#> # A tibble: 6 × 6
#> time pupil pupil_left pupil_right blink_left blink_right
#> <dbl> <dbl> <int> <int> <int> <int>
#> 1 0 1776. 1657 1894 0 0
#> 2 1 1776 1657 1895 0 0
#> 3 2 1776. 1658 1895 0 0
#> 4 3 1776. 1659 1894 0 0
#> 5 4 1775 1658 1892 0 0
#> 6 5 1774. 1657 1890 0 0A BIDS recording is one continuous stream, so by default each run is
a single trial and time is ms from its first sample. That
suits resting-state or other continuous recordings.
For a task with trials, give trial_start one or more
messages that mark the start of each trial. A new trial starts at every
message beginning with one of them, time is ms from that
message, and samples before the first one are dropped. In this EyeLink
example, every trial starts with a TRIALID message:
pn010 <- read_bids_eyetrack(bids_dir, eye = "left", trial_start = "TRIALID")
pn010 |>
group_by(trial) |>
summarise(samples = n(), duration_ms = max(time), blink_pct = 100 * mean(blink))
#> # A tibble: 6 × 4
#> trial samples duration_ms blink_pct
#> <int> <int> <dbl> <dbl>
#> 1 1 9062 9061 0.706
#> 2 2 6055 6054 3.07
#> 3 3 5069 5068 0
#> 4 4 4068 4067 0
#> 5 5 4069 4068 0
#> 6 6 6067 6066 1.88You can also start trials at task events,
e.g. trial_start = c("LONG_TRIAL_START", "SHORT_TRIAL_START").
A trial then runs until the next of those messages, so it can include
the inter-trial interval.
The messages give the events within each trial, ready for
time-locking (e.g. with
baseline_correction_pupil_msg()):
pn010 |>
filter(!is.na(message), !startsWith(message, "!")) |>
select(trial, time, message) |>
head(8)
#> # A tibble: 8 × 3
#> trial time message
#> <int> <dbl> <chr>
#> 1 1 41 LONG_TRIAL_START
#> 2 1 9041 LONG_TRIAL_END
#> 3 2 34 SHORT_TRIAL_START
#> 4 2 6034 SHORT_TRIAL_END
#> 5 3 48 SHORT_TRIAL_START
#> 6 3 5048 SHORT_TRIAL_END
#> 7 4 47 SHORT_TRIAL_START
#> 8 4 4047 SHORT_TRIAL_ENDMessages logged while the tracker was not recording (between trials)
have no sample of their own, so they attach to the next recorded sample.
Here, the setup messages and TRIALID of each trial land on
its first sample, and so do the Experiment Builder trial variables
(!V TRIAL_VAR) of the previous trial, which are
logged after its recording ends. For EyeLink data,
read_asc() assigns trial variables to the right trial. With
BIDS data, take trial information from your behavioural or
events.tsv files.
The format is the same for every tracker, so
read_bids_eyetrack() also reads BIDS data from Tobii, Pupil
Labs and others. Trackers use different clocks (Pupil Labs timestamps
are in ns, Tobii’s in µs), so timestamps are converted to ms using the
Units of timestamp in the sidecar
(s, ms, us or ns).
If no units are given, they are inferred from the step between samples
and SamplingFrequency. The sidecar details for each
participant are kept in an attribute:
attr(read_bids_eyetrack(bids_dir), "bids_info")
#> subject sample_rate eyes_recorded manufacturer pupil_units
#> 1 sub-PN010_task-bws 1000 both SR-Research arbitrary
#> blink_events
#> 1 TRUECheck pupil_units before choosing a plausible pupil
range (e.g. 2–9 mm for Pupil Labs, arbitrary units for EyeLink). If
there are no blink events (blink_events is
FALSE, as with most Tobii data), detect blinks from the
signal, e.g. with detect_blinks_noise().
Point read_bids_eyetrack() at the top of the dataset and
it reads every participant, session, task and run. To read only some,
pass the files:
files <- list.files("my_dataset", pattern = "task-rest.*_physio\\.tsv\\.gz$",
recursive = TRUE, full.names = TRUE)
rest <- read_bids_eyetrack(files)
rest |> distinct(subject, sample_rate, eyes_recorded)Check that the participants’ sampling rates match: blink extension,
interpolation gaps and filtering all depend on it.
resample_gaze() can bring them to a common rate:
The output of read_bids_eyetrack() goes straight into
the pupil functions (extend_blinks(),
smooth_interpolate_pupil(),
baseline_correction_pupil_msg(), …); see the pupil
pre-processing vignette. shiny gazeR can
also read BIDS eye-tracking data: choose BIDS
(eye-tracking) as the eye tracker and upload a
.zip of the dataset.