Skip to main content
Ctrl+K
pymovements  documentation - Home pymovements  documentation - Home
  • User Guide
  • Tutorials
  • Datasets
  • API Reference
  • Contributing
    • About Us
    • Bibliography
  • GitHub
  • User Guide
  • Tutorials
  • Datasets
  • API Reference
  • Contributing
  • About Us
  • Bibliography
  • GitHub

Section Navigation

  • pymovements in 10 minutes
  • Downloading Public Datasets
  • Working with a Local Dataset
  • Parsing SR Research EyeLink Data
  • Plotting Gaze Data
  • Preprocessing Raw Gaze Data
  • Saving and Loading Preprocessed Data
  • Handling Gaze Events
  • Creating Synthetic Data
  • Detecting Blinks from the Pupil Signal
  • Cleaning Gaze Data During Blinks
  • How to use pymovements in R
  • Tutorials
  • Detecting Blinks from the Pupil Signal

Detecting Blinks from the Pupil Signal#

Many eye trackers (e.g., EyeLink) provide hardware-detected blink markers, but some do not. Even when they do, you may want an independent detection method for validation or for trackers that only provide a raw pupil signal.

pymovements includes a blink detection function that identifies blinks directly from the pupil size time series. The algorithm adapts the differential detection approach of Hershman et al. (2018) with a two-stage pipeline inspired by PupilPre (Kyrolainen et al., 2019):

  1. Flagging – samples where pupil is NaN/zero or shows rapid changes (large absolute difference)

  2. Island absorption – short unflagged gaps between flagged regions are absorbed

Duration defaults (50–500 ms) follow Nystrom et al. (2024).

This tutorial demonstrates:

  • Loading EyeLink data with from_asc()

  • Inspecting the recording structure (participant, session, duration)

  • Running algorithmic blink detection with gaze.detect('blink')

  • Listing all detected blink instances with timing and duration

  • Visualizing each blink in context

  • Comparing with EyeLink hardware blink events

  • Tuning detection parameters

import matplotlib.pyplot as plt
import numpy as np
import polars as pl

import pymovements as pm
from pymovements.events import blink as blink_fn
from pymovements.gaze.io import from_asc

1. Load EyeLink Data#

We use the ToyDatasetEyeLink dataset. The file name encodes the participant and session: subject_1_session_1.asc.

We load with events=True so that hardware blink events (blink_eyelink) are also available for comparison later.

# Download the dataset
dataset = pm.Dataset('ToyDatasetEyeLink', path='data/ToyDataset')
dataset.download()

# Load the first ASC file
raw_dir = dataset.paths.raw / 'pymovements-toy-dataset-eyelink-main'
asc_file = raw_dir / 'raw' / 'subject_1_session_1.asc'

gaze = from_asc(
    asc_file,
    patterns='eyelink',
    encoding='ascii',
    events=True,
)

print(f'File:    {asc_file.name}')
print('Subject: 1, Session: 1')
print(f'Samples: {gaze.samples.shape}')
print(f'Columns: {gaze.samples.columns}')
gaze.samples.head()
INFO:pymovements.dataset.dataset:
        You are downloading the pymovements Toy Dataset EyeLink. Please be aware that pymovements does not
        host or distribute any dataset resources and only provides a convenient interface to
        download the public dataset resources that were published by their respective authors.

        Please cite the referenced publication if you intend to use the dataset in your research.
        
Using already downloaded and verified file: data/ToyDataset/downloads/pymovements-toy-dataset-eyelink.zip
Extracting pymovements-toy-dataset-eyelink.zip to data/ToyDataset/raw
Extracting archive:   0%|          | 0/4 [00:00<?, ?file/s]
Extracting archive: 100%|██████████| 4/4 [00:00<00:00, 88.81file/s]

File:    subject_1_session_1.asc
Subject: 1, Session: 1
Samples: (128342, 3)
Columns: ['time', 'pupil', 'pixel']
shape: (5, 3)
timepupilpixel
i64f64list[f64]
2154556778.0[138.1, 132.8]
2154557778.0[138.2, 132.7]
2154558778.0[138.2, 132.3]
2154559778.0[138.1, 131.9]
2154560777.0[137.9, 131.6]

2. Understand the Recording#

This ASC file contains a single continuous recording of one participant in one session.

The time column is in milliseconds (EyeLink native), but the values are large absolute timestamps (ms since the tracker started). We convert to seconds relative to the recording start for readable plots.

time_arr = gaze.samples['time'].to_numpy()
pupil_arr = gaze.samples['pupil'].to_numpy()

# Recording timing
t0 = time_arr[0]
duration_s = (time_arr[-1] - t0) / 1000

print(f'EyeLink time range: {t0:.0f} – {time_arr[-1]:.0f} ms')
print(f'Recording duration: {duration_s:.1f} s ({duration_s / 60:.1f} min)')
print(f'Sampling rate:      ~{len(time_arr) / duration_s:.0f} Hz')
print(f'Total samples:      {len(time_arr)}')
EyeLink time range: 2154556 – 2339291 ms
Recording duration: 184.7 s (3.1 min)
Sampling rate:      ~695 Hz
Total samples:      128342

3. Inspect the Raw Pupil Signal#

The pupil signal typically drops to zero or NaN during blinks. Let’s plot the full recording using time relative to the start (in seconds) so the x-axis is easy to interpret.

# Relative time in seconds for all plots
time_s = (time_arr - t0) / 1000

fig, ax = plt.subplots(figsize=(14, 3))
ax.plot(time_s, pupil_arr, color='mediumpurple', linewidth=0.5)
ax.set_xlabel('Time since recording start (s)')
ax.set_ylabel('Pupil Size')
ax.set_title(
    f'Raw Pupil Signal — Subject 1, Session 1 '
    f'(full recording, {duration_s:.0f} s)',
)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
../_images/4ee97a1e4ae045c148802051c771db651f98d74f3f69eb107644f27acea42364.png

4. Detect Blinks#

We use gaze.detect('blink') which calls the blink detection algorithm. The function automatically reads the pupil column from the gaze dataframe.

The detection algorithm adapts the differential approach of Hershman et al. (2018) as implemented in PupilPre (Kyrolainen et al., 2019).

Default parameters:

  • delta=None – auto-estimated from the 95th percentile of absolute pupil differences

  • minimum_gap=3 – short gaps up to 3 samples are absorbed

  • minimum_candidates_around_gap=2 – at least 2 flagged samples on each side to absorb a gap

  • minimum_duration=50 – events shorter than 50 ms are discarded

  • maximum_duration=500 – events longer than 500 ms are discarded

The 50–500 ms duration defaults follow Nystrom et al. (2024), who established that typical blinks fall within this range. Events outside this window are likely artifacts (too short) or extended eye closures (too long).

gaze.detect('blink')

detected_blinks = gaze.events.frame.filter(pl.col('name') == 'blink')
print(f'Detected {len(detected_blinks)} blink events')
Detected 19 blink events

Complete list of all detected blinks#

Each row is one blink event. We show onset/offset (original EyeLink ms), duration, and time relative to recording start for easy reference.

blink_table = detected_blinks.select(
    pl.col('onset'),
    pl.col('offset'),
).with_columns(
    (pl.col('offset') - pl.col('onset')).alias('duration_ms'),
    ((pl.col('onset') - t0) / 1000).round(2).alias('onset_s'),
).with_row_index('blink_nr', offset=1)

# Show ALL rows — no truncation
with pl.Config(tbl_rows=-1):
    print(blink_table)
shape: (19, 5)
┌──────────┬─────────┬─────────┬─────────────┬─────────┐
│ blink_nr ┆ onset   ┆ offset  ┆ duration_ms ┆ onset_s │
│ ---      ┆ ---     ┆ ---     ┆ ---         ┆ ---     │
│ u32      ┆ i64     ┆ i64     ┆ i64         ┆ f64     │
╞══════════╪═════════╪═════════╪═════════════╪═════════╡
│ 1        ┆ 2157534 ┆ 2157598 ┆ 64          ┆ 2.98    │
│ 2        ┆ 2159345 ┆ 2159418 ┆ 73          ┆ 4.79    │
│ 3        ┆ 2159479 ┆ 2159589 ┆ 110         ┆ 4.92    │
│ 4        ┆ 2165696 ┆ 2165769 ┆ 73          ┆ 11.14   │
│ 5        ┆ 2170325 ┆ 2170407 ┆ 82          ┆ 15.77   │
│ 6        ┆ 2173746 ┆ 2173840 ┆ 94          ┆ 19.19   │
│ 7        ┆ 2178356 ┆ 2178469 ┆ 113         ┆ 23.8    │
│ 8        ┆ 2184393 ┆ 2184475 ┆ 82          ┆ 29.84   │
│ 9        ┆ 2185247 ┆ 2185331 ┆ 84          ┆ 30.69   │
│ 10       ┆ 2190069 ┆ 2190146 ┆ 77          ┆ 35.51   │
│ 11       ┆ 2194512 ┆ 2194580 ┆ 68          ┆ 39.96   │
│ 12       ┆ 2197809 ┆ 2197876 ┆ 67          ┆ 43.25   │
│ 13       ┆ 2202977 ┆ 2203072 ┆ 95          ┆ 48.42   │
│ 14       ┆ 2203451 ┆ 2203558 ┆ 107         ┆ 48.9    │
│ 15       ┆ 2205853 ┆ 2205925 ┆ 72          ┆ 51.3    │
│ 16       ┆ 2211368 ┆ 2211444 ┆ 76          ┆ 56.81   │
│ 17       ┆ 2214193 ┆ 2214287 ┆ 94          ┆ 59.64   │
│ 18       ┆ 2218178 ┆ 2218276 ┆ 98          ┆ 63.62   │
│ 19       ┆ 2220161 ┆ 2220488 ┆ 327         ┆ 65.6    │
└──────────┴─────────┴─────────┴─────────────┴─────────┘

5. Blink-by-Blink Visualization#

We plot each detected blink with ~500 ms of context on each side, so you can see the pupil drop in detail. Time on the x-axis is relative to the blink onset (in ms).

blink_onsets = detected_blinks['onset'].to_list()
blink_offsets = detected_blinks['offset'].to_list()

n_blinks = len(blink_onsets)
ncols = min(5, n_blinks)
nrows = max(1, int(np.ceil(n_blinks / ncols)))
context_ms = 500  # ms of context before/after blink

fig, axes = plt.subplots(
    nrows, ncols, figsize=(ncols * 3, nrows * 2.2), squeeze=False,
)

for idx in range(n_blinks):
    r, c = divmod(idx, ncols)
    ax = axes[r, c]

    onset = blink_onsets[idx]
    offset = blink_offsets[idx]
    dur = offset - onset
    win_start = onset - context_ms
    win_end = offset + context_ms

    mask = (time_arr >= win_start) & (time_arr <= win_end)
    # Time relative to blink onset (ms)
    t_rel = time_arr[mask] - onset

    ax.plot(t_rel, pupil_arr[mask], color='mediumpurple', linewidth=0.8)
    ax.axvspan(0, dur, alpha=0.3, color='coral')

    ax.set_title(f'#{idx + 1} ({dur} ms)', fontsize=8)
    ax.tick_params(labelsize=6)
    ax.set_yticks([])

# Hide unused subplots
for idx in range(n_blinks, nrows * ncols):
    r, c = divmod(idx, ncols)
    axes[r, c].set_visible(False)

fig.suptitle(
    f'All {n_blinks} Detected Blinks — Pupil Signal '
    f'(coral = blink, x = ms from onset)',
    fontsize=11, fontweight='bold',
)
plt.tight_layout()
plt.show()
../_images/2c2e665d0a4690680f9d4249c61fd50fa637ee1f98ff552d17e09157a98f5de9.png

6. Compare with EyeLink Hardware Blinks#

Since we loaded with events=True, we also have blink_eyelink events from the EyeLink hardware detector. Let’s compare both.

hw_blinks = gaze.events.frame.filter(pl.col('name') == 'blink_eyelink')
print(f'Hardware (EyeLink) blinks: {len(hw_blinks)}')
print(f'Algorithmic blinks:        {len(detected_blinks)}')

hw_onsets = hw_blinks['onset'].to_list()
hw_offsets = hw_blinks['offset'].to_list()

if len(hw_onsets) > 0:
    # Window around the first few hardware blinks
    focus_start = hw_onsets[0] - 500
    focus_end = hw_offsets[min(2, len(hw_offsets) - 1)] + 500
    mask = (time_arr >= focus_start) & (time_arr <= focus_end)
    focus_t_s = (time_arr[mask] - focus_start) / 1000

    fig, ax = plt.subplots(figsize=(14, 3.5))
    ax.plot(focus_t_s, pupil_arr[mask], color='mediumpurple', linewidth=0.8)

    for i, (on, off) in enumerate(zip(hw_onsets, hw_offsets)):
        if off >= focus_start and on <= focus_end:
            ax.axvspan(
                (on - focus_start) / 1000, (off - focus_start) / 1000,
                alpha=0.2, color='steelblue',
                label='EyeLink' if i == 0 else None,
            )

    for i, (on, off) in enumerate(zip(blink_onsets, blink_offsets)):
        if off >= focus_start and on <= focus_end:
            ax.axvspan(
                (on - focus_start) / 1000, (off - focus_start) / 1000,
                alpha=0.2, color='coral',
                label='Algorithmic' if i == 0 else None,
            )

    ax.legend(loc='upper right')
    ax.set_xlabel('Time (s)')
    ax.set_ylabel('Pupil Size')
    ax.set_title('Hardware vs. Algorithmic Blink Detection')
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
else:
    print('No hardware blink events found for comparison.')
Hardware (EyeLink) blinks: 19
Algorithmic blinks:        19
../_images/23e2f7afe51fe4f172191f22c1d56e343c8a9e27ee9608a822ed2b0ad08ab5f4.png

7. Parameter Tuning#

What is delta?#

delta is a threshold on sample-to-sample pupil change: if |pupil[i+1] - pupil[i]| > delta, both samples get flagged as part of a blink.

Why it matters: The algorithm first flags samples where pupil is 0 or NaN (the middle of a blink where the tracker lost the pupil entirely). But blinks also have ramps — the pupil signal drops rapidly as the eyelid closes and recovers rapidly as it opens. delta catches these transitional samples at the blink edges.

When delta=None (default): It is auto-estimated as 5 x 95th percentile of all valid |diff(pupil)|. This adapts to each recording — noisy data gets a higher threshold, clean data gets a lower one.

When to change it:

Situation

What you see

What to do

False positives (non-blink artifacts flagged)

Pupil constriction or noise marked as blinks

Increase delta (e.g., 200-500)

Missed blinks (edges not captured)

Blink regions too narrow, missing the onset/offset ramps

Decrease delta (e.g., 20-50)

Very noisy recording

Auto-delta is too high due to overall noise

Set delta explicitly to a fixed value

Clean recording

Auto-delta works fine

Leave as None

deltas = [None, 50.0, 200.0, 500.0]

# Focus window around first few blinks
if len(blink_onsets) > 0:
    focus_start = blink_onsets[0] - 500
    focus_end = blink_offsets[min(2, len(blink_offsets) - 1)] + 500
else:
    focus_start = t0
    focus_end = t0 + 5000

mask = (time_arr >= focus_start) & (time_arr <= focus_end)
focus_t_s = (time_arr[mask] - focus_start) / 1000

fig, axes = plt.subplots(len(deltas), 1, figsize=(14, 2.5 * len(deltas)), sharex=True)

for ax, d in zip(axes, deltas):
    events = blink_fn(
        pupil=pupil_arr,
        timesteps=time_arr.astype(int),
        delta=d,
    )

    ev_df = events.frame
    ax.plot(focus_t_s, pupil_arr[mask], color='mediumpurple', linewidth=0.8)

    if len(ev_df) > 0:
        for row in ev_df.to_dicts():
            on, off = row['onset'], row['offset']
            if off >= focus_start and on <= focus_end:
                ax.axvspan(
                    (on - focus_start) / 1000, (off - focus_start) / 1000,
                    alpha=0.3, color='coral',
                )

    label = f'delta={d}' if d is not None else 'delta=auto'
    ax.set_ylabel('Pupil', fontsize=9)
    ax.set_title(f'{label} — {len(ev_df)} events total', fontsize=10)
    ax.grid(True, alpha=0.3)

axes[-1].set_xlabel('Time (s)')
fig.suptitle('Effect of delta on Blink Detection', fontsize=13, fontweight='bold')
plt.tight_layout()
plt.show()
../_images/44b7f7d031ee3f2aec1f99d5a243398ab26f5712b9e8a017817f65b5c0bff76c.png

What is minimum_gap?#

minimum_gap controls island absorption — merging short unflagged gaps between flagged regions. During a blink, the tracker sometimes recovers valid pupil readings for 1-3 samples in the middle of an otherwise complete blink. Without absorption, this splits one real blink into two or more events.

  • minimum_gap=0 — disables absorption entirely (no merging)

  • minimum_gap=3 (default) — gaps of up to 3 unflagged samples are absorbed if surrounded by at least minimum_candidates_around_gap=2 flagged samples on each side

  • minimum_gap=10 — aggressive merging, useful for very noisy data

When to change it: If you see single blinks split into multiple events, increase it. If distinct close blinks are being incorrectly merged, decrease it.

runs = [0, 1, 3, 10]

fig, axes = plt.subplots(len(runs), 1, figsize=(14, 2.5 * len(runs)), sharex=True)

for ax, mvr in zip(axes, runs):
    events = blink_fn(
        pupil=pupil_arr,
        timesteps=time_arr.astype(int),
        minimum_gap=mvr,
    )

    ev_df = events.frame
    ax.plot(focus_t_s, pupil_arr[mask], color='mediumpurple', linewidth=0.8)

    if len(ev_df) > 0:
        for row in ev_df.to_dicts():
            on, off = row['onset'], row['offset']
            if off >= focus_start and on <= focus_end:
                ax.axvspan(
                    (on - focus_start) / 1000, (off - focus_start) / 1000,
                    alpha=0.3, color='coral',
                )

    ax.set_ylabel('Pupil', fontsize=9)
    ax.set_title(f'minimum_gap={mvr} — {len(ev_df)} events total', fontsize=10)
    ax.grid(True, alpha=0.3)

axes[-1].set_xlabel('Time (s)')
fig.suptitle(
    'Effect of minimum_gap on Blink Detection', fontsize=13, fontweight='bold',
)
plt.tight_layout()
plt.show()
../_images/bafa5f0caf4012e998514a05c0a51b9cdf9bd07be8f2348b41f396e56c99a493.png

Duration filtering: minimum_duration and maximum_duration#

Following Nystrom et al. (2024), typical blinks last 50–500 ms. The defaults minimum_duration=50 and maximum_duration=500 filter out events outside this range:

  • Events < 50 ms are likely partial tracking losses or noise, not true blinks

  • Events > 500 ms are likely extended eye closures or prolonged tracking loss

Set maximum_duration=None to disable the upper bound (e.g., if you want to capture extended eye closures too).

# Compare: default duration filter vs. no upper bound
events_default = blink_fn(
    pupil=pupil_arr,
    timesteps=time_arr.astype(int),
)
events_no_max = blink_fn(
    pupil=pupil_arr,
    timesteps=time_arr.astype(int),
    maximum_duration=None,
)
events_strict = blink_fn(
    pupil=pupil_arr,
    timesteps=time_arr.astype(int),
    minimum_duration=80,
    maximum_duration=300,
)

print(f'Default (50-500 ms):   {len(events_default.frame)} blinks')
print(f'No upper bound (50+ ms): {len(events_no_max.frame)} blinks')
print(f'Strict (80-300 ms):    {len(events_strict.frame)} blinks')

# Show the duration distribution
if len(events_no_max.frame) > 0:
    durations = events_no_max.frame['duration'].to_numpy()
    fig, ax = plt.subplots(figsize=(10, 3))
    ax.hist(durations, bins=30, color='mediumpurple', edgecolor='white', alpha=0.8)
    ax.axvline(50, color='coral', linestyle='--', linewidth=2, label='min=50 ms')
    ax.axvline(500, color='steelblue', linestyle='--', linewidth=2, label='max=500 ms')
    ax.set_xlabel('Blink Duration (ms)')
    ax.set_ylabel('Count')
    ax.set_title('Blink Duration Distribution (no upper bound)')
    ax.legend()
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
Default (50-500 ms):   19 blinks
No upper bound (50+ ms): 19 blinks
Strict (80-300 ms):    10 blinks
../_images/99d7464a392f06f0797e2e80a26f1c76cd6479fa2e84ddb4479ba90467965c5b.png

Summary#

  • blink() detects blinks from the pupil signal without relying on eye-tracker markers

  • The algorithm adapts Hershman et al. (2018) as implemented in PupilPre (Kyrolainen et al., 2019)

  • Duration defaults follow Nystrom et al. (2024): 50–500 ms

  • Key parameters: delta (sensitivity), minimum_gap (gap merging), minimum_duration / maximum_duration (duration filtering)

  • Use gaze.detect('blink') for seamless integration with the pymovements pipeline

  • For cleaning blink artifacts from gaze data, see the Blink Cleaning tutorial

previous

Creating Synthetic Data

next

Cleaning Gaze Data During Blinks

On this page
  • 1. Load EyeLink Data
  • 2. Understand the Recording
  • 3. Inspect the Raw Pupil Signal
  • 4. Detect Blinks
    • Complete list of all detected blinks
  • 5. Blink-by-Blink Visualization
  • 6. Compare with EyeLink Hardware Blinks
  • 7. Parameter Tuning
    • What is delta?
    • What is minimum_gap?
    • Duration filtering: minimum_duration and maximum_duration
  • Summary
Show Source

© Copyright 2022-2025 The pymovements Project Authors.

Created using Sphinx 8.2.3.

Built with the PyData Sphinx Theme 0.18.0.