Skip to main content

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

  1. Load center-eye gaze direction.
  2. Compute the angular velocity between consecutive samples.
  3. Classify each sample as fixation or saccade based on a velocity threshold.
  4. 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

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())

Gotchas

  • Saccade threshold is empirical. 30 deg/s is a common starting point but every dataset / device is different. Plot the ang_vel histogram — 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.
  • HitObject beats direction for attention. If EyeTracking.csv includes Eye_*_HitObject / Eye_*_HitUV columns, use those directly — they tell you what the gaze ray hit, which is usually what you actually care about.

Further reading