Analzying Pupil Labs Neon Data With GazeR

r
pupillometry
statistics
I demonstrate how to analyze pupil data from Pupil Labs Neon mobile eye-tracker with my eye-tracking R package gazeR.
Published

October 31, 2025

🎃 Happy Halloween, goils and boils! 👻

I have a spooktacular blog post for you today—one that dives into the mysterious world of pupil data from Pupil Labs Neon glasses. We’re currently using the Neon in the classroom to study mind wandering and attention. However, these mobile eye trackers aren’t just for field studies—they shine in the lab too.

To demonstrate this, I created a simple PsychoPy experiment that interfaces seamlessly with the Neon (you can find it here: https://osf.io/txz59/overview ). In the task, participants view a bright sun for 30 seconds followed by a dark patch for another 30 seconds. The pupil responds to basic visual features like brightness—constricting in light and dilating in the dark.

In this post, I’ll show how the Neon glasses can capture these pupillary dynamics and how you can use my R package {gazeR} to preprocess pupil data collected from Pupil Labs devices.

gazeR Pupil labs Functions

Once you collect data with Pupil Labs Neon, your recordings live in Pupil Cloud. Export the Time Series for each recording (CSV export). After the export finishes, you’ll have a folder per participant/recording containing multiple CSV files.

From each participant folder, we use exactly three files:

gaze.csv — gaze samples (timestamps, x/y pixels, fixations, blinks)

3d_eye_states.csv — pupil diameters (left/right, in mm)

events.csv — experiment events/messages (e.g., trial markers)

I created two new functions to read and process this data from Pupil Labs: parse_pl() and process_all_subjects_PL(). Both work in tandem to prepare Neon data for use with {gazeR}.

What the functions do

parse_pl(subject_dir, start_mode = c("any","exact"), start_messages = NULL, max_event_lag_ms = 20)

Processes one participant:

  1. Reads the three CSVs and converts timestamps to milliseconds.

  2. Joins pupil data to gaze samples.

  3. Aligns each non-recording event (events.csv) to the nearest gaze row (within max_event_lag_ms).

  4. Creates trial indices:

  • start_mode = “any” → any non-empty message (excluding recording.begin/.end) starts a new trial.

  • start_mode = “exact” → only messages listed in start_messages start a new trial (robust to case/whitespace/hyphen differences).

  1. Removes blank trials (those without a valid start message).

  2. Resets time to 0 at the first row of each trial.

  3. Returns a tidy tibble ready for {gazeR}: subject, trial, time, x, y, pupil, blink, message.

process_all_subjects_PL(root_dir, output_dir = file.path(root_dir, "processed"), ...)

Batch-processes all immediate subfolders of root_dir using parse_pl(), writes one CSV per subject plus a combined files

  • Per-subject files: {output_dir}/{SUBJECT}_processed.cs

    Combined file: {output_dir}/all_subjects_processed.csv

Any additional arguments (...) are passed straight to parse_pl() (e.g., start_mode, start_messages, max_event_lag_ms).

Example Dataset

Let’s read in the dataset created from the above functions.

We will load in {gazeR} and needed libraries.

remotes::install_github("dmirman/gazer") # gazer package
library(gazer) # load in 
library(tidyverse) # viz
library(knitr) # tables
sub_pl =read.csv("https://osf.io/zf2qu/download") # get processed file from osf

kable(head(sub_pl))
subject trial time x y pupil blink message
2025-10-31_12-48-26-74964918 1 0.000000 813.938 606.636 4.24975 FALSE trial-started-light
2025-10-31_12-48-26-74964918 1 5.005127 815.400 608.674 4.21485 FALSE
2025-10-31_12-48-26-74964918 1 9.994873 815.163 607.715 4.23690 FALSE
2025-10-31_12-48-26-74964918 1 14.994629 814.249 606.579 4.25715 FALSE
2025-10-31_12-48-26-74964918 1 19.994629 812.541 607.620 4.22035 FALSE
2025-10-31_12-48-26-74964918 1 24.994873 815.149 607.083 4.20380 FALSE

Let’s take a look at the data we have.

This is what the pupil data looks like for the entire time course.

Unlikely Pupil Sizes

Now, keep only plausible pupil diameters between 2 mm and 9 mm.

pup_outliers<-pup_missing |>
  dplyr::filter (pup_interp  >= 2, pup_interp <= 9)

MAD

Get rid of artifacts we might have missed during some earlier steps.

  #MAD removal
max_removal<-pup_missing  |>
  dplyr::group_by(subject, trial) |>
  dplyr::mutate(speed=speed_pupil(pup_interp,time)) |>
  dplyr::mutate(MAD=calc_mad(speed)) |>
  dplyr::filter(speed < MAD)

Onset

Let’s only look from the start of the trial until 1000 ms

pupil_onset<-max_removal |>
  dplyr::group_by(subject, trial) |>
  dplyr::filter(time <= 1000) |>
  select(subject, trial, time, pup_interp)

Downsample

Downsample the time-course to 100 ms.

#downsample
timebins1<- downsample_gaze(pupil_onset, bin.length=100, pupil="pup_interp", timevar = "time", aggvars = c("subject", "trial", "timebins"), type="pupil")

kable(head(timebins1))
subject trial timebins aggbaseline
2025-10-31_12-48-26-74964918 1 0 4.233871
2025-10-31_12-48-26-74964918 1 100 4.251030
2025-10-31_12-48-26-74964918 1 200 4.264931
2025-10-31_12-48-26-74964918 1 300 4.296879
2025-10-31_12-48-26-74964918 1 400 4.323454
2025-10-31_12-48-26-74964918 1 500 4.247790

Visualize Time-course