Gaze paths
Compute fixations (gaze held stable on a point) and saccades (rapid shifts between fixations) from EyeTracking.csv. The basis for dwell-time, scan-path, and attention-allocation analyses.
What this recipe does
- Load center-eye gaze direction.
- Compute the angular velocity between consecutive samples.
- Classify each sample as fixation or saccade based on a velocity threshold.
- Group consecutive fixation samples into fixation events with (start, end, duration, mean direction).
Files you need
EyeTracking.csv— gaze direction over time.ExperienceState.csv— optional, for tagging each fixation with the epoch it occurred in.
Code
- Python (pandas)
- R
import pandas as pd
import numpy as np
eye = pd.read_csv("EyeTracking.csv")
# Parameters.
saccade_threshold_deg_per_s = 30.0 # above this = saccade, below = fixation
min_fixation_ms = 60 # discard fixations shorter than this
# 1. Center-eye gaze direction (already normalised).
dir_cols = ["Eye_Center_DirX", "Eye_Center_DirY", "Eye_Center_DirZ"]
dirs = eye[dir_cols].to_numpy()
t = eye["MonotonicExecutionTime"].to_numpy()
# 2. Angular velocity: angle between consecutive direction vectors / dt.
dot = np.einsum("ij,ij->i", dirs[:-1], dirs[1:]).clip(-1.0, 1.0)
ang_rad = np.arccos(dot)
ang_deg = np.rad2deg(ang_rad)
dt = np.diff(t)
ang_vel_dps = np.divide(ang_deg, dt, out=np.zeros_like(ang_deg), where=dt > 0)
# Pad the first sample with 0 velocity so array shapes align with `eye`.
ang_vel = np.concatenate([[0.0], ang_vel_dps])
eye = eye.assign(ang_vel_dps=ang_vel)
eye["is_fixation"] = eye["ang_vel_dps"] < saccade_threshold_deg_per_s
# 3. Group consecutive fixation samples into fixation events.
eye["fix_id"] = (eye["is_fixation"] != eye["is_fixation"].shift()).cumsum()
fixation_samples = eye[eye["is_fixation"]]
fixations = (
fixation_samples
.groupby("fix_id")
.agg(
t_start=("MonotonicExecutionTime", "min"),
t_end =("MonotonicExecutionTime", "max"),
dirX =("Eye_Center_DirX", "mean"),
dirY =("Eye_Center_DirY", "mean"),
dirZ =("Eye_Center_DirZ", "mean"),
n_samples=("MonotonicExecutionTime", "size"),
)
.reset_index(drop=True)
)
fixations["duration_ms"] = (fixations["t_end"] - fixations["t_start"]) * 1000.0
# 4. Drop sub-threshold fixations.
fixations = fixations[fixations["duration_ms"] >= min_fixation_ms]
print(f"n_fixations = {len(fixations)}")
print(fixations["duration_ms"].describe())
library(data.table)
eye <- fread("EyeTracking.csv")
saccade_threshold_deg_per_s <- 30.0
min_fixation_ms <- 60
dirs <- as.matrix(eye[, .(Eye_Center_DirX, Eye_Center_DirY, Eye_Center_DirZ)])
t <- eye$MonotonicExecutionTime
dots <- pmin(pmax(rowSums(dirs[-nrow(dirs), ] * dirs[-1, ]), -1), 1)
ang_deg <- acos(dots) * 180 / pi
dt <- diff(t)
ang_vel <- ifelse(dt > 0, ang_deg / dt, 0)
eye[, ang_vel_dps := c(0, ang_vel)]
eye[, is_fixation := ang_vel_dps < saccade_threshold_deg_per_s]
eye[, fix_id := cumsum(is_fixation != shift(is_fixation, fill = FALSE))]
fixations <- eye[is_fixation == TRUE,
.(
t_start = min(MonotonicExecutionTime),
t_end = max(MonotonicExecutionTime),
dirX = mean(Eye_Center_DirX),
dirY = mean(Eye_Center_DirY),
dirZ = mean(Eye_Center_DirZ),
n_samples = .N
),
by = fix_id
]
fixations[, duration_ms := (t_end - t_start) * 1000]
fixations <- fixations[duration_ms >= min_fixation_ms]
summary(fixations$duration_ms)
Gotchas
- Saccade threshold is empirical. 30 deg/s is a common starting point but every dataset / device is different. Plot the
ang_velhistogram — there's usually a clear bimodal split between fixation (low velocity) and saccade (high velocity). Put the threshold in the valley between the two peaks. - Tracker dropout shows up as huge velocities. A blink or tracker-reacquisition can produce a sample where the direction vector jumps from e.g.
(0, 0, 1)to(0, 0, 0)and back. Filter out samples with zeroed direction vectors before computing velocity. - Noise vs. microsaccade. Under 30 deg/s samples include both stable fixation and tiny microsaccades. For most experimental-psychology questions the distinction doesn't matter; for oculomotor research it does.
- Head movement contaminates gaze.
Eye_Center_Dir*is in environment coordinates, so if the head rotates, the gaze vector changes even if the participant is fixating a stable point in the world. For head-locked gaze analyses, rotate by the inverse of the head pose from Participant. - Sampling rate. At 90 Hz, a "60 ms minimum fixation" is ~5 samples. Below that threshold you're chasing noise.
HitObjectbeats direction for attention. IfEyeTracking.csvincludesEye_*_HitObject/Eye_*_HitUVcolumns, use those directly — they tell you what the gaze ray hit, which is usually what you actually care about.
Further reading
- Eye
- Participant — for head-relative gaze.
- Stimulus-locked averaging — pair fixations with stimulus onsets.