Blink Detection

While it is generally agreed that blinks need to be taken care of during preprocessing, most eye tracking software does not include this event information. This has lead to the development of several great blink algorithms. gazeR includes two:

  1. A velocity-based detector (blink_velocity()) — simple and intuitive; finds blinks by looking at how fast the pupil signal is changing.
  2. A noise-based detector (detect_blinks_noise()) — implements Hershman, Henik & Cohen (2018); starts from the tracker’s missing-data sentinel and refines each blink’s onset and offset by walking outward on the smoothed first-difference signal.

This vignette walks through both. The velocity-based steps below illustrate what is happening inside blink_velocity(); the final section shows how to call the noise-based detector directly.

First load gazeR and some sample data.

library(gazer)
library(cowplot)
library(data.table)
library(patchwork)
library(tidyverse)
pupil_path <- system.file("extdata", "pupil_sample_files_edf.xls.gz", package = "gazer")
pupil_files1<-fread(pupil_path)
pupil_files1 <- as_tibble(pupil_files1)
summary(pupil_files1)
##       subject           trial             time           pupil      
##  Length   :585898   Min.   :  1.00   Min.   :    0   Min.   :1535   
##  N.unique :     3   1st Qu.: 43.00   1st Qu.: 1316   1st Qu.:3073   
##  N.blank  :     0   Median : 82.00   Median : 2640   Median :3241   
##  Min.nchar:     7   Mean   : 79.29   Mean   : 2950   Mean   :3231   
##  Max.nchar:     7   3rd Qu.:117.00   3rd Qu.: 3996   3rd Qu.:3395   
##                     Max.   :148.00   Max.   :15948   Max.   :5794   
##                                                      NAs    :60620  
##        x                   y                 blink             message      
##  Min.   :    -1221   Min.   :     -879   Min.   :0.0000   Length   :585898  
##  1st Qu.:      944   1st Qu.:      517   1st Qu.:0.0000   N.unique :    14  
##  Median :      995   Median :      543   Median :0.0000   N.blank  :     0  
##  Mean   : 10138828   Mean   : 10138430   Mean   :0.1005   Min.nchar:     3  
##  3rd Qu.:     1054   3rd Qu.:      569   3rd Qu.:0.0000   Max.nchar:    40  
##  Max.   :100000000   Max.   :100000000   Max.   :1.0000   NAs      :583075  
##                                                                             
##       acc             block             rt               item       
##  Min.   :0.0000   Min.   :0.000   Min.   :  446   Length   :585898  
##  1st Qu.:1.0000   1st Qu.:1.000   1st Qu.:  980   N.unique :   149  
##  Median :1.0000   Median :2.000   Median : 1787   N.blank  :     0  
##  Mean   :0.8461   Mean   :1.522   Mean   : 2620   Min.nchar:     5  
##  3rd Qu.:1.0000   3rd Qu.:2.000   3rd Qu.: 3380   Max.nchar:    10  
##  Max.   :1.0000   Max.   :2.000   Max.   :14673                     
##                                                                     
##        script              alt        
##  Length   :585898   Length   :585898  
##  N.unique :     2   N.unique :     4  
##  N.blank  :     0   N.blank  :     0  
##  Min.nchar:     5   Min.nchar:     2  
##  Max.nchar:     7   Max.nchar:     4  
##                                       
## 

Worked example: Tobii Titta data + noise-based detection

The walkthrough above used a 250 Hz EyeLink recording. To show the noise-based detector end-to-end on a different tracker, the package also ships one trimmed trial recorded with a Tobii spectrum at 120 Hz, captured via the Python Titta package. The gaze and message CSVs live in inst/extdata/titta_gaze/ and inst/extdata/titta_msg/ (paired by basename).

merge_tobii_titta() reads the two folders, attaches each message to the nearest gaze sample, assigns trials from the onset_ / offset_ markers, and rebases time to be trial-relative in ms.

gaze_dir <- system.file("extdata", "titta_gaze", package = "gazer")
msg_dir  <- system.file("extdata", "titta_msg",  package = "gazer")

titta_raw <- merge_tobii_titta(gaze = gaze_dir, msg = msg_dir)

Tobii reports left and right eyes separately. Average them with compute_monocular_mean() (which returns NA only when both eyes are missing), then run the result through make_gazer() so the standard subject / trial / time / x / y / pupil columns end up at the front of the data frame:

titta <- titta_raw %>%
  mutate(
    pupil = compute_monocular_mean(left_pupil_diameter, right_pupil_diameter),
    x     = compute_monocular_mean(left_gaze_point_on_display_area_x,
                                   right_gaze_point_on_display_area_x),
    y     = compute_monocular_mean(left_gaze_point_on_display_area_y,
                                   right_gaze_point_on_display_area_y)
  ) %>%
  make_gazer(subject = "subject", trial = "trial", time = "time",
             x = "x", y = "y", pupil = "pupil")

head(titta[, c("subject", "trial", "time", "x", "y", "pupil")])
## # A tibble: 6 × 6
##   subject   trial  time     x     y pupil
##   <chr>     <int> <dbl> <dbl> <dbl> <dbl>
## 1 DS21_2120     1  0    0.506 0.478  3.21
## 2 DS21_2120     1  8.33 0.507 0.475  3.22
## 3 DS21_2120     1 16.7  0.507 0.477  3.22
## 4 DS21_2120     1 25.0  0.509 0.477  3.22
## 5 DS21_2120     1 33.3  0.508 0.478  3.22
## 6 DS21_2120     1 41.7  0.506 0.477  3.23

From here, the rest of the pipeline is identical to the EyeLink walkthrough above — only hz = 120 differs. Apply the speed filter, run detect_blinks_noise(), then interpolate_pupil():

# 1. speed_pupil + MAD filter
titta <- titta %>%
  dplyr::group_by(subject, trial) %>%
  dplyr::mutate(speed = speed_pupil(pupil, time)) %>%
  dplyr::ungroup()
thr <- calc_mad(titta$speed[!is.na(titta$speed) & is.finite(titta$speed)], n = 16)
titta <- titta %>%
  dplyr::mutate(pupil = ifelse(!is.na(speed) & is.finite(speed) & speed > thr,
                               NA_real_, pupil))

# 2. detect blinks + 3. interpolate
titta <- titta %>%
  dplyr::group_by(subject, trial) %>%
  dplyr::mutate(blink = as.integer(detect_blinks_noise(pupil, hz = 120) > 0)) %>%
  dplyr::ungroup() %>%
  interpolate_pupil(pupil = "pupil", subject = "subject", trial = "trial",
                    extendblinks = FALSE, type = "linear", hz = 120)
titta_windows <- titta %>%
  dplyr::filter(blink > 0) %>%
  dplyr::mutate(grp = cumsum(c(1, diff(time) > 1000 / 120 + 1))) %>%
  dplyr::group_by(grp) %>%
  dplyr::summarise(start = min(time), end = max(time), .groups = "drop")

ggplot(titta, aes(x = time, y = pupil)) +
  geom_rect(data = titta_windows,
            aes(xmin = start, xmax = end, ymin = -Inf, ymax = Inf),
            inherit.aes = FALSE,
            fill = "darkorange", alpha = 0.12) +
  geom_line(colour = "grey60") +
  geom_line(aes(y = interp), colour = "darkorange", linewidth = 0.7) +
  xlab("Time (ms)") +
  ylab("Pupil size (mm)") +
  ggtitle("Titta trial: speed filter + noise-based detection + interpolation") +
  theme_bw(base_size = 13)

The same detect_blinks_noise() call worked on both trackers without changing the algorithm — only hz differed (250 for EyeLink, 120 for Tobii).