Flexible, Extensible, & Reproducible Pupillometry Preprocessing
Overview
Pupillometry offers a non-invasive window into the mind and has been used extensively as a psychophysiological readout of arousal signals linked with cognitive processes like attention, stress, and emotional states [Clewett et al. (2020) doi:10.1038/s41467-020-17851-9; Kret & Sjak-Shie (2018) doi:10.3758/s13428-018-1075-y; Strauch (2024) doi:10.1016/j.tins.2024.06.002]. Yet, despite decades of pupillometry research, many established packages and workflows to date lack design patterns based on Findability, Accessibility, Interoperability, and Reusability (FAIR) principles [see Wilkinson et al. (2016) doi:10.1038/sdata.2016.18]. 'eyeris' provides a modular, performant, and extensible preprocessing framework for pupillometry data with BIDS-like organization and interactive output reports [Esteban et al. (2019) doi:10.1038/s41592-018-0235-4; Gorgolewski et al. (2016) doi:10.1038/sdata.2016.44]. Development was supported, in part, by the Stanford Wu Tsai Human Performance Alliance, Stanford Ric Weiland Graduate Fellowship, Stanford Center for Mind, Brain, Computation and Technology, NIH National Institute on Aging Grants (R01-AG065255, R01-AG079345), NSF GRFP (DGE-2146755), McKnight Brain Research Foundation Clinical Translational Research Scholarship in Cognitive Aging and Age-Related Memory Loss, American Brain Foundation, and the American Academy of Neurology.
This minor release changes how missing pupil data is handled, broadens the range of eye trackers eyeris can ingest, and refreshes every diagnostic figure. The headline **breaking change** is that interpolate() no longer interpolates across arbitrarily long gaps: stretches of consecutive missing samples longer than the new max_gap_ms limit (default 250 ms, following Kret & Sjak-Shie, 2018) are now left as NA, and the downstream steps that cannot operate on missing data (lpfilt(), downsample(), bin(), detrend()) now work *around* those gaps and restore them, rather than being skipped. New features include load_generic(), a tracker-agnostic loader that lets non-EyeLink data enter the pipeline (with glassbox() now accepting a pre-loaded eyeris object); a sampling-uniformity guardrail plus an automatic resample() step for hardware that silently *drops* samples instead of zero-filling them; spline detrending via detrend(method = "spline"); and simulate_eyeris()/sim_params() for generating seeded, synthetic, pipeline-compatible pupil data with known ground truth. Bug fixes restore the fitted detrend coefficients on the glassbox() output, repair per-run epoch CSV writing in bidsify() for both multi-run objects and run_num overrides, and resolve a multi-block crash on R >= 4.2. Under the hood, all diagnostic plotting migrated from base graphics to reaborn/ggplot2.
🚨 **Breaking changes & deprecations**
- **BREAKING (#295)**:
interpolate()now enforces a maximum gap duration for linear interpolation via a newmax_gap_msparameter. Gaps of consecutive missing (NA) samples that are **longer** thanmax_gap_msare now left asNArather than interpolated over. The default of250ms follows the recommendation of Kret & Sjak-Shie (2018), who advise against interpolating across long gaps where linear interpolation is unlikely to reflect the true underlying pupil signal. Previously (eyeris ≤ 3.2.0), **all** gaps were interpolated regardless of duration. This is a change in default behavior and may affect downstream results;eyerisnow prints a console notice (at most once perglassbox()run) when the limit is in effect. To restore the previous behavior of interpolating across all gaps, setinterpolate = list(max_gap_ms = Inf)inglassbox()(ormax_gap_ms = Infwhen callinginterpolate()directly). The threshold can also be customized, e.g.,glassbox(interpolate = list(max_gap_ms = 100)). The limit is specified in milliseconds and is internally converted to a number of samples using each recording's own sampling rate, so it behaves consistently across sampling frequencies, by @shawntz in #318. - **ENH (#295)**: The downstream
glassbox()steps that cannot operate on missing data — low-pass filtering (lpfilt()), downsampling (downsample()), binning (bin()), and detrending (detrend()) — now work *around* the gaps thatinterpolate(max_gap_ms)intentionally leaves asNA. They filter/resample/fit over the available data (filling temporarily where needed) and then restore the gaps asNA, so the long-gapNAs are preserved through to the final preprocessed output instead of causing these steps to be skipped. When interpolation has not been run upstream, the filter/resample steps still raise the usual "interpolate first" error, by @shawntz in #318. - **ENH (#295)**:
lpfilt()anddownsample()now emit a warning when they filter over gaps longer thanmax_gap_ms(left asNAbyinterpolate()). Because a low-pass/anti-aliasing filter cannot run on missing data, these gaps are temporarily filled and then masked back toNA; the filter can therefore slightly bias the valid pupil samples immediately adjacent to each gap toward the (near-linear) interpolated values — analogous to the edge artifacts seen at the start/end of a filtered recording, but repeated at every long gap. The warning lets you decide whether to disable filtering and/or downsampling (lpfilt = FALSEand/ordownsample = FALSE) to avoid introducing this systematic bias, at the cost of retaining more high-frequency content. The console warning fires at most once perglassbox()run, and the same information is recorded as a **"Data Quality Notes"** section in the HTML preprocessing report so it is captured as part of the data provenance, by @shawntz in #318.
🚀 New features
- **NF**: **
detrend()now supports spline detrending via a newmethodargument.** Detrending previously only removed a straight-line (linear) trend, which cannot capture the slow, *nonlinear* drift often present in longer recordings.detrend()now acceptsmethod = "linear"(the default, preserving the previous behavior of regressing pupil size on time) ormethod = "spline", which removes a smooth, potentially nonlinear trend by fitting a natural cubic spline basis of time (splines::ns(time, df = spline_df)). The flexibility of the fitted spline is controlled by the newspline_dfargument (degrees of freedom; default5). Both models return the fitted trend and the residuals exactly as before, so all downstream steps, plots, and reports are unchanged aside from the trend shape. Enable it in the pipeline withglassbox(detrend = list(method = "spline"))(optionallyspline_df = ...), or calldetrend(method = "spline")directly, by @shawntz in #349. - **NF (#299)**: **Added
load_generic(), a tracker-agnostic data loader for non-EyeLink eye trackers.** Until now,load_asc()(for SR Research EyeLink.ascfiles) was the only implemented loader, which prevented researchers using other eye trackers from running their data througheyeris.load_generic()accepts three standardized data frames —pupil(raw samples: timestamp + pupil size, optionally gazeeye_x/eye_y),events(timestamp + message text), andblinks(blink start/end intervals) — plus an optional fourthgazedata frame for the less common case where gaze is exported separately from pupil size (joined ontopupilby timestamp). These are assembled into the exact sameeyerisS3 object thatload_asc()produces, making the result a drop-in input toglassbox(),epoch(),bidsify(), and the plotting methods. Column names are configurable via themappingargument; sampling rate is inferred from the timestamps when not supplied; timestamps may be provided in milliseconds (default) or seconds (time_unit = "s"); andblock/eye/pupil_type/screen-dimension metadata mirrorload_asc(). Native readers for specific tracker formats can now be layered on top of this function incrementally, by @shawntz in #311. - **NF**: **
glassbox()now accepts a pre-loadedeyerisobject** (e.g., fromload_generic()), automatically skipping the file-loading step and running the remaining pipeline on the object directly. This makesload_generic(...) |> glassbox()work as expected, and also means an already-loadedload_asc()object piped intoglassbox()now processes correctly instead of raising an error, by @shawntz in #311. - **ENH (#300)**: Added a hardware-quirk guardrail that detects non-uniform sampling intervals in the input timeseries. Different eye trackers behave differently when pupil data is missing: EyeLink zero-fills, but some hardware silently *drops* samples, leaving gaps in the otherwise evenly spaced time vector. Because downstream pipeline steps (e.g.,
detransient(),lpfilt(),downsample()) assume a fixed sampling rate, such gaps can silently distort results. The new internalcheck_uniform_sampling_intervals()helper infers the expected inter-sample interval from the data (robust to dropped samples and sub-millisecond timestamp rounding), checks each recording segment independently so that legitimate between-block gaps are not flagged, and emits an informative warning estimating the number of dropped samples when irregular intervals are detected. It also cross-checks the data-derived interval against the device's reported sampling rate to catch *systematic* dropout (e.g., every Nth sample missing), which leaves a uniform but coarser grid that gap detection alone cannot see. The check is wired intoload_asc()so the quirk is surfaced early, before any preprocessing begins, by @shawntz in #308. - **ENH (#320)**: Added an automatic
resample()step that repairs the **time axis** of recordings whose hardware *drops* samples (instead of zero-filling) when pupil data is missing — the dropped-sample quirk surfaced by the #300 guardrail. Whenglassbox()detects irregular sampling intervals (gated on the robustcheck_uniform_sampling_intervals()detector from #300), it places each block onto the expected uniform sampling grid in two stages: (1) it anchors the grid on the first *reliable* regular interval (rather than the first timestamp), so early sub-period timing jitter doesn't offset the whole grid, and back-extends the grid to cover any earlier samples; then (2) it resamples the observed data onto that grid — linearly interpolating across short/jittered intervals, and insertingNArows for any interval longer than the expected period (dropped samples), flagged in a new logicalis_resampledcolumn and left for the existinginterpolate()step to fill. Because a gap is now represented as explicit missing data (never fabricated values),interpolate()decides how much of a missing span to fill according to its own policy (see the #295max_gap_mslimit). This turns the "dropped-sample" problem into the ordinary "missing-value" problem the rest of the pipeline already handles. It runs **automatically and by default**, but only when needed: it is a guaranteed no-op for already-uniform data (e.g., EyeLink) and deliberately leaves high-rate recordings that report integer-millisecond timestamps untouched (so it never collapses genuine sub-millisecond samples). Opt out withglassbox(resample = FALSE).summarize_confounds()now also reportsn_resampled/prop_resampledper block, by @shawntz in #320. - **NF**: **Added
simulate_eyeris()andsim_params(), a seeded synthetic pupil data generator with known ground truth.**eyerispreviously had no way to produce synthetic pupil data, making it difficult to write self-contained tests, build reproducible documentation examples, or demonstrate how a preprocessing step behaves without shipping and loading a real recording.sim_params()builds the signal-model parameter list, with every component independently toggle-able and validated: tonic baseline, slow drift, hippus, Hoeks & Levelt phasic responses, blinks (missing cores plus occlusion flank spikes), transient spikes, broadband noise, and optional line noise.simulate_eyeris()then generates the signal under a confined RNG (withr::with_seed(), so the global.Random.seedis left untouched) and delegates to the same internal object constructor used byload_asc(), so the returnedeyerisobject is structurally identical to a real recording and flows through the entire pipeline unchanged (deblink()…zscore(),glassbox(),plot(),epoch(),summarize_confounds()). This addition is purely additive and changes no existing behavior, by @shawntz in #351.
Show all changes in 3.3.0Hide the rest of the changes in 3.3.0
🐛 Bugs fixed
- **FF**: Fixed
glassbox(detrend = TRUE)silently dropping the fitted detrend coefficients. The per-block detrend coefficients are computed insidepipeline_handler()and stored on the internal per-block object, butglassbox()'s block-recombine step copied back the timeseries,params, decimated sample rate, and pre-decimation data — never$detrend_coefs. As a result,$detrend_coefswas always absent from the object returned byglassbox(), even though it was correctly populated when callingdetrend()directly. The recombine step (in both the main and per-eye/binocular paths) now preserves$detrend_coefsper block, so the fitted intercept/slope are available on theglassbox()output for provenance and inspection. It also clears any$detrend_coefsinherited from a pre-loaded input object at the start of each run, so the returned object retains only the coefficients produced in that run (and none survive when detrending is disabled or fails). Thedetrend_fitted_valuescolumn that drives the detrend diagnostic plots/reports was already preserved and is unaffected, by @shawntz in #350. - **FF**: Fixed an error in
summarize_confounds()(viaget_block_numbers()) that aborted withthe condition has length > 1when processing multi-block data on R ≥ 4.2. The internalis.na()guard now handles vectors of block numbers, so multi-block recordings flow through the confound summary step correctly. This latent bug was surfaced by the new multi-blockload_generic()tests, by @shawntz in #311. - **FF**: Fixed multi-run epoch CSV files (
*_desc-preproc_pupil_epoch-<label>.csv) not being written for each run. When a subject's data contained multiple blocks (runs),bidsify()built the per-epoch preprocessed output filename from therun_numargument — which is intentionally ignored (NULL) for multi-block objects — and packed the epoch label directly into thedescfield without therun-andepoch-BIDS entities. As a result, every run wrote to the *same* filename and silently overwrote the previous one (last-run-wins), so the expected..._run-NN_desc-preproc_pupil_epoch-<label>.csvfiles never materialized for multi-run inputs. The multi-run writer now derives the run number from each block and routes the epoch label through the BIDS filename builder, exactly mirroring the single-run path. The DuckDB/parquet outputs were already keyed by the correct per-run number and are unchanged — only the CSV filenames were affected. As part of the same fix, the multi-run raw-timeseries writers now key therun-NNtoken off each block's own block number (rather than a positional index, which mislabeled runs when blocks were not numbered sequentially), and several internal epoch/baseline metadata helpers no longer receiveverbosein theirblock_nameargument position (which crashedbidsify()underverbose = FALSEwhenever epochs were present), by @alicexue and @shawntz in #322. - **FF**: Fixed the
run_numoverride being ignored for epoch CSV and per-runmetadata.jsonoutputs when preprocessing separate single-block.ascfiles into a sharedbids_dir. In that workflow the run number is not forced atload_asc(), so every file loads asblock_1and the per-filerun_numis instead passed tobidsify(); two outputs ignored theblock_1→block_<run_num>rename and collapsed ontorun-01, so every run's*_desc-preproc_pupil_epoch-<label>.csvoverwrote itself and every run's*_metadata.jsonended up holding the last run'ssource_file/call_stack. The epoch-block rename now writes the renamed block key back onto the epoch list (previously it updated only a local copy of the block-name vector), and per-run metadata regeneration inmake_report()is scoped to the blocks present in the current object rather than everyrun-XXdirectory found on disk — so sibling runs written in earlier loop iterations keep their own metadata and are only read back for the combined report, by @shawntz in #334.
🔧 Under the hood
- **ENH**: Migrated all diagnostic plotting from base graphics to
reaborn— an R port of the 'Python' 'seaborn' library built on 'ggplot2'.plot.eyeris(), the gaze heatmap (plot_gaze_heatmap()), the binocular-correlation panels (plot_binocular_correlation()), the detrend overlay, the pupil-size distribution histograms, the progressive-summary report plot, and the per-epoch gallery figures are now rendered withreabornand composited withpatchwork, giving the interactive HTML reports a consistent, publication-quality seaborn aesthetic while preserving eyeris's colour palette and axis labels. Each plotting function now buildsggplotobjects and prints them to the active graphics device, so the existing report/gallery capture flow (png()/jpeg()→ draw →dev.off()) is unchanged and every public plotting API keeps its signature. Multi-panel time series previews are composited withpatchwork(replacing basepar(mfrow)layouts), missing-sample gaps are shaded as contiguous regions, and the binocu
The NEWS section is longer than shown here.
From NEWS.md in the release
Install
Health
- OK13 OK2026-09-30
- ERROR12 OK, 1 ERROR2026-09-04
- OK13 OK2026-08-05
- NOTE12 OK, 1 NOTE2026-08-01
- OK13 OK2026-06-20
Show 9 earlier changesHide 9 earlier changes
- ERROR12 OK, 1 ERROR2026-06-19
- OK13 OK2026-06-09
- ERROR12 OK, 1 ERROR2026-06-08
- OK13 OK2026-05-24
- ERROR12 OK, 1 ERROR2026-05-17
- OK13 OK2026-05-15
- ERROR12 OK, 1 ERROR2026-05-13
- OK12 OK2026-04-25
- ERROR11 OK, 2 NOTE, 1 ERROR2026-03-10
Documentation
- Help pages
- 191 (152 internal)includes a package overview page
- Examples that run
- 63%24 run in a plain check · 6 only when donttest examples are enabled (R CMD check --as-cran) · 8 never run
- Exported functions with examples
- 89%
- Documented parameters
- 99%
- Return-value docs
- 100%
- Help pages citing references
- 1%
- Anatomy of an eyeris Object HTML
- Building Your Own Custom Pipeline Extensions HTML
- Built for the Age of AI Coding Agents HTML
- Chunked eyerisdb Database Export for Large Datasets HTML
- Complete Pupillometry Pipeline Walkthrough HTML
- Extracting Data Epochs and Exporting Pupil Data HTML
- Internal API Reference HTML
- Preprocessing Multiple Runs Stored in Separate Files HTML
- QC with Interactive Reports HTML
- Working with eyeris Databases: A Complete Guide HTML
Downloads
Repository
- PRs merged (all time)
- 98%
- Median days to close, 50 most recently updated closed issues
- 6
- Median days to close, 50 most recently updated closed PRs
- 0
Stars over time
Forks over time
Releases over time
Issues over time
PRs over time
Commits over time
Commit identities over time
18 development-tooling and community-health practices detected across 6 families in the upstream repository
Checks run against github.com/shawntz/eyeris on 2026-10-21.
The R CMD check workflow runs on Linux, macOS and Windows, and on R-devel
Funding links: GitHub Sponsors, Patreon, Buy Me a Coffee
The repository owner accepts GitHub Sponsors
GitHub Pages site last deployed 2026-10-07 · The repository has a site deploy workflow
pkgdown site last built 2026-10-07
Show all practicesHide 2 practices
This repository shows signs of AI coding tools.
- GitHub Copilotfirst seen 2025-12-22Pull request: opened by its own accountCommits: 14 by its own account · at least 1 crediting it in the message
- Cursorfirst seen 2026-06-03Pull request it wrote: opened by a project memberCommits: 5 by its own account
- Claude Codefirst seen 2026-06-05Named in .Rbuildignore: .claudePull request it wrote: opened by a project memberCommits: at least 126 crediting it in the message
- Claude Opus 4.893 commits · Jun to Jul 2026
- Claude Opus 5.57 commits · Oct 2026
Some of these counts are a minimum, because the search stopped before the oldest commits.
Dependencies
Installing eyeris adds 72 packages (37 with compiled code, 70 MB of source tarballs). Heaviest: stringi 11 MB, ggplot2 6.0 MB, data.table 5.7 MB.
Code & Tests
People & History
Show 8 of 10 peopleHide 8 people
Pick two releases to compare their code metrics.
- 3.3.0Latest2026-07-31 · current release · diff ↗ ·
added 4 exports
load_generic, resample, sim_params, simulate_eyeris - 3.2.0
- 3.1.02026-06-05 · diff ↗
- RR 4.6.0 released · 2026-04-24
- 3.0.12025-10-07 · diff ↗
- 3.0.02025-09-17 · diff ↗ ·
added 12 exports
eyeris_db_collect, eyeris_db_connect, eyeris_db_disconnect, eyeris_db_list_tables, eyeris_db_read, eyeris_db_reconstruct_from_chunks, eyeris_db_split_for_sharing, eyeris_db_summary, eyeris_db_to_chunked_files, eyeris_db_to_parquet, process_chunked_query, read_eyeris_parquet - 2.1.12025-07-26 · diff ↗
- 2.1.0
- 2.0.02025-07-03 · diff ↗ ·
added 5 exports
bin, downsample, eyeris_color_palette, plot_gaze_heatmap, summarize_confounds - 1.2.1
- 1.2.02025-06-07 · diff ↗
- 1.1.0
- RR 4.5.0 released · 2025-04-11
- 1.0.02025-03-31
- RR 4.4.0 released · 2024-04-24
Package metadata
- First published
- 2025-03-31
- Total releases
- 12 / 1 yrs
- License
- MIT + file LICENSE OSI
- Minimum R
- ≥ 4.1
- Download size
- 3.5 MB
Cite
Cite this package
Run in R for the authors' entry:
citation("eyeris")The package provides a citation file, so citation() returns the authors' own entry.
It points to 10.1101/2025.06.01.657312.
Cite the R Observatory
For a number measured here: a download total, a coverage figure, an archival date.
From data release v2026-10-23, which the citation names so these numbers can be found later. More on citing and the projects behind them.