From-scratch Python implementation of Tobii's I-VT velocity-threshold filter
Project description
Tobii I-VT Filter Reconstruction
A from-scratch Python implementation of Tobii's I-VT (Velocity-Threshold) filter. Classifies eye-tracking data into fixations, saccades, and unclassified samples based on gaze velocity.
Stable release:
1.0.0is the first stable version with a settled public API. See CHANGELOG.md for details.
What Does This Do?
Eye-trackers record raw sample data at high frequency (typically 60–1200 Hz). To understand what someone is looking at, you need to:
- Recognize fixations: periods where the eye is relatively stable
- Recognize saccades: rapid eye movements between fixations
- Ignore unclassified noise: invalid data, blinks, or ambiguous movements
This tool automates that classification using a velocity-based approach: if the eye moves faster than a threshold (e.g., 30°/s), it's a saccade; otherwise it's a fixation.
How the Algorithm Works
The filter applies a processing pipeline to each eye-tracking dataset:
Raw eye-tracking data
↓
[1] Fill temporal gaps (interpolation)
[2] Tobii eye-offset interpolation (optional)
↓
[3] Select eye (left, right, or average)
↓
[4] Reduce noise (smoothing)
↓
[5] Calculate velocity (degrees/second)
↓
[6] Classify (Fixation if v < threshold, else Saccade)
↓
[7] Merge nearby fixations (post-processing)
↓
[8] Filter short fixations (optional)
↓
Output: classified eye-tracking events
Key Concepts
Velocity Calculation: The algorithm computes how fast the eye is moving in 2D or 3D space. We support four methods:
- Olsen 2D (simple, Tobii-compatible): Uses only the Z distance from eye to screen. Fast but less accurate for lateral movements.
- Ray 3D (accurate): Full 3D ray-casting between eye position and gaze point. Most accurate but requires complete eye position data.
- Ray 3D with Gaze Direction (robust): Uses DACS NORM gaze direction vectors.
- Tobii Gaze Dir (Tobii-exact): Uses the same angular formula as Tobii Pro Lab for maximum compatibility.
Windowing: Velocity is calculated over a small window of samples (recommended 20 ms (3 samples for 120 Hz)). Larger windows = smoother but less responsive; smaller windows = more responsive but noisier. (Always depends on the frequency!)
Smoothing (optional): Apply median filter or moving average to reduce noise before classification. Helps with micro-fixations caused by measurement jitter.
Post-Processing: Merge saccades separated by brief periods (e.g., <75 ms) and discard very short fixations (<60 ms) that are likely noise.
Strict I-VT Baseline and Optional Reconstruction Heuristics
The default classifier is a strict I-VT baseline. After validity handling, each finite velocity is classified with one inclusive threshold comparison:
velocity < threshold→Fixationvelocity >= threshold→Saccade- invalid eye samples →
EyesNotFound - missing or non-finite velocity →
Unclassified
Reconstruction heuristics are opt-in and are not part of the baseline algorithm:
| Option | Behavior when enabled |
|---|---|
--enable-invalid-window-neighbor-confirmation |
Requires an adjacent above-threshold velocity before an invalid-window sample can become a saccade. |
--enable-hysteresis |
Retains the previous motion label while velocity remains in the band immediately below the saccade threshold. |
--hysteresis-width <deg/s> |
Sets the width of that optional hysteresis band (default: 2.0). |
--enable-near-threshold-hybrid |
Allows alternative-velocity refinement near the threshold. |
--enable-eye-jump-rule |
Allows alternative-velocity correction for eye-position jumps. |
--confident-switch-enabled |
Allows confident alternative-velocity switching away from the threshold. |
Classifier output contains diagnostic columns including classifier_refinement_rules_enabled, classifier_invalid_window_neighbor_confirmation_enabled, classifier_hysteresis_enabled, and classifier_hysteresis_width_deg_per_sec, so exported runs state which optional rules were active.
Installation & Setup
1. Clone and Install
git clone git@github.com:cemGr/Tobii-I-VT-Filter-Reconstruction.git
cd Tobii-I-VT-Filter-Reconstruction
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
Plotting is optional. Install the plotting extra only if you want to generate figures:
pip install tobii-ivt-filter[plot]
Supported Python Import Paths
Use the canonical module paths below in applications and examples. The package does not maintain duplicate root-level compatibility modules for these APIs.
| API | Supported import path |
|---|---|
IVTPipeline |
ivt_filter.io.pipeline |
PipelineObserver, ConsoleReporter, MetricsLogger, ExperimentTracker, ResultsPlotter |
ivt_filter.io.observers |
ExperimentConfig, ExperimentManager |
ivt_filter.evaluation.experiment |
estimate_sampling_rate |
ivt_filter.utils.sampling |
| Window sizing helpers | ivt_filter.utils.window_utils |
2. Prepare Your Data
The filter expects TSV (tab-separated values) with these columns:
time_us gaze_left_x_mm gaze_left_y_mm eye_left_z_mm validity_left ...
0 123.45 456.78 450.0 0
1000 123.46 456.79 450.1 0
2000 123.47 456.80 450.0 0
Required columns:
time_usortime_ms: Timestampgaze_left_x_mm,gaze_left_y_mm: Left eye gaze position (mm)gaze_right_x_mm,gaze_right_y_mm: Right eye gaze position (mm)eye_left_z_mm,eye_right_z_mm: Eye-to-screen distance (mm)validity_left,validity_right: Validity code (0=valid, else=invalid)
Optional:
eye_left_x_mm,eye_left_y_mm: Left eye position (needed for Ray 3D)eye_right_x_mm,eye_right_y_mm: Right eye position- Ground truth column (e.g.,
Eye movement type) for evaluation
Quick Workflow Example
Here's a typical workflow for processing a file:
Step 1: Extract Data from Raw Format
If you export from Tobii Pro Lab, use the built-in extractor:
python extractor.py --input raw_tobii_export.tsv --output data.tsv --timestamp-unit auto
This converts the Tobii export to the slim format needed by the I-VT filter. It automatically:
- Detects timestamp units and populates millisecond and microsecond timestamps
- Maps Tobii column names to standard format
- Excludes calibration samples
- Removes rows without stimulus names
If your data is in another format (e.g., CSV), convert to the expected columns (see "Prepare Your Data").
Step 2: Run the I-VT Filter
Apply the recommended settings:
python -m ivt_filter.cli \
--input data.tsv \
--output data_classified.tsv \
--eye average \
--velocity-method tobii_gaze_dir \
--window 20 \
--auto-fixed-window-from-ms \
--threshold 30 \
--smoothing median_strict \
--smooth-window-samples 3 \
--shifted-valid-window \
--shifted-valid-fallback shrink \
--classify \
--merge-close-fixations \
--merge-fix-max-gap-ms 75 \
--merge-fix-max-angle-deg 0.5 \
--discard-short-fixations \
--min-fixation-duration-ms 60 \
--time-column time_us \
--time-unit us \
--with-events
What Each Parameter Does
| Parameter | Value | Purpose |
|---|---|---|
--input |
data.tsv |
Input file path |
--output |
data_classified.tsv |
Output file path |
--eye |
average |
Use both eyes (left, right, or average) |
--velocity-method |
tobii_gaze_dir |
How to calculate velocity (Tobii-exact, maximum compatibility) |
--window |
20 |
Time window in milliseconds |
--auto-fixed-window-from-ms |
- | Auto-convert ms window to sample window |
--threshold |
30 |
Saccade threshold: 30°/s |
--smoothing |
median_strict |
Apply strict median smoothing (skip if window contains invalid samples) |
--smooth-window-samples |
3 |
Smooth over 3 samples |
--shifted-valid-window |
- | Handle windows that cross validity changes gracefully |
--shifted-valid-fallback |
shrink |
Shrink window if needed (vs unclassified) |
--classify |
- | Classify samples as Fixation/Saccade |
--merge-close-fixations |
- | Merge fixations that are temporally/spatially close |
--merge-fix-max-gap-ms |
75 |
Max time gap between fixations to merge (ms) |
--merge-fix-max-angle-deg |
0.5 |
Max angular distance between fixation centers to merge (°) |
--discard-short-fixations |
- | Remove fixations <60 ms (noise) |
--time-column |
time_us |
Name of your timestamp column |
--time-unit |
us |
Unit of timestamps (us or ms) |
--with-events |
- | Output event-level grouping |
--evaluate |
- | If you have ground truth, compute metrics (optional) |
Understanding the Parameters
Velocity Method
Tobii Gaze Dir (tobii_gaze_dir, recommended for Tobii data):
- Uses the same angular formula as Tobii Pro Lab: θ = 2·asin(‖v₁−v₂‖/2)
- Numerically more stable than acos(dot product) for small angles
- Requires normalized gaze direction vectors (DACS norm)
- Use when you need results that match Tobii Pro Lab exactly
Ray 3D with Gaze Direction (ray3d_gaze_dir):
- Uses normalized gaze direction vectors (DACS norm), acos(dir₀·dir₁)
- Does not require screen or eye position data
- Robust for real-world data where eye positions are incomplete
Ray 3D (ray3d):
- Requires both X/Y/Z eye position (not all eye-trackers provide this)
- Pure 3D calculation, very accurate
- Falls back to Olsen 2D if eye position is missing
Olsen 2D (olsen2d):
- Uses only Z distance; ignores X/Y eye position
- Fast, but less accurate for side-to-side movements
- Use only if you need compatibility with the Olsen (2012) paper or eye position data is unavailable
Window Selection
Auto-Convert ms to Samples (recommended):
--window 20 --auto-fixed-window-from-ms
- Automatically converts time window (e.g., 20 ms) to sample window
- Adapts to your actual sampling rate (e.g., 20 ms @ 120 Hz → 3 samples)
- Best of both worlds: specify intuitive ms values, get stable sample windows
- Uses
--dt-calculation-method(mean or median) for conversion
Fixed Sample Window (explicit):
--fixed-window-samples 3
- Directly specify number of samples
- Most consistent across different sampling rates
- 3 samples gives good balance: not too noisy, not too sluggish
- At 120 Hz: ~25 ms window; at 60 Hz: ~50 ms window
Time-Based Window (not recommended):
--window 20
- Fixed 20 ms window without sample conversion
- Can be unstable if sampling rate varies
- Use
--auto-fixed-window-from-msinstead for better results
Smoothing
Median Filter – Strict (recommended):
--smoothing median_strict --smooth-window-samples 3
- Reduces jitter without blurring saccades
- Robust to outliers
- Strict variant: skips smoothing entirely if the window contains any invalid samples
- Good for noisy data
Median Filter – Adaptive:
--smoothing median_adaptive --smooth-window-samples 5
- Collects only valid samples within the window
- Can search beyond the nominal window if needed (set with
--smoothing-expansion-radius) - Use when invalid samples are frequent and you still want smoothing
Moving Average:
--smoothing moving_average_strict --smooth-window-samples 5
- Simple but aggressive
- Blurs saccades slightly
- Use if median isn't enough
No Smoothing:
--smoothing none
- Use when comparing to published Tobii results or for diagnostics
- Can be noisier
Preprocessing: Tobii Eye-Offset Interpolation
--tobii-eye-offset-interpolation
When one eye drops out briefly (a common Tobii artifact), the missing eye position is reconstructed using the last known left-to-right eye offset. This prevents phantom velocities that would otherwise appear at the edges of data gaps when running in average eye mode.
Use whenever:
- You are processing Tobii data with
--eye average - Your data has short single-eye outages
Shifted Valid Window
--shifted-valid-window --shifted-valid-fallback shrink
- Handles windows that span valid→invalid transitions
shrink: Use shorter window if no constant-length valid block existsunclassified: Mark sample as unclassified instead- Keeps you from getting NaN velocities due to one bad sample
Post-Processing
Merge Close Fixations:
--merge-close-fixations --merge-fix-max-gap-ms 75 --merge-fix-max-angle-deg 0.5
- Two fixations separated by <75 ms and <0.5° → merge into one
- Reduces fragmentation from brief saccades
- Adjust to your use case (e.g., stricter for precise gaze analysis)
Discard Short Fixations:
--discard-short-fixations --min-fixation-duration-ms 60
- Removes fixations shorter than 60 ms
- Helps with noise-induced micro-fixations
Velocity Calculation in Detail
The Problem
Raw eye-tracking gives you position (X, Y) at each millisecond. You need velocity to classify:
Sample 1: (100, 200) at t=0 ms
Sample 2: (105, 205) at t=5 ms ← only 5 mm movement
Sample 3: (130, 225) at t=10 ms ← large jump (25 mm)
Is this a fixation or saccade? Depends on how fast that jump is. But how do you convert mm to deg/s?
The Solution: Ray Casting
Imagine a ray from your eye through your gaze point into the world. When you move your eye, that ray rotates. The rotation speed is the angular velocity in degrees/second.
Ray 3D Method:
Eye position: E = (ex, ey, ez)
Gaze point: G = (gx, gy, gz)
Ray from E to G: R = G - E
Previous ray: R_old
Current ray: R_new
Angle between them: θ = acos(R_old · R_new / (|R_old| × |R_new|))
Angular velocity: v_deg_per_sec = θ / Δt × (180 / π)
Tobii Gaze Dir / Ray 3D Gaze Dir (using normalized direction vectors):
dir_old, dir_new: normalized gaze direction vectors
Tobii Gaze Dir: θ = 2·asin(‖dir_new − dir_old‖ / 2)
Ray 3D Gaze Dir: θ = acos(dir_old · dir_new)
Olsen 2D Method (simplified):
Uses only Z distance (eye-to-screen) and 2D gaze offsets:
Angle: θ = atan(screen_distance / eye_z)
Faster but ignores X/Y eye position changes.
Example Calculation
Imagine:
- Eye at (0, 0, 600 mm) from screen
- Looking at screen point (50, 0) mm
- Moves to (60, 0) mm in 10 ms
Ray 3D:
R_old = (50, 0, -600) → |R_old| = 610 mm
R_new = (60, 0, -600) → |R_new| = 611 mm
dot product ≈ 600² + 50×60 = 366000
cos(θ) ≈ 0.999...
θ ≈ 0.47°
v ≈ 47°/s ← Saccade (if threshold=30)
Output Format
After running the filter, your TSV includes these new columns:
| Column | Example | Meaning |
|---|---|---|
velocity_deg_per_sec |
2.5 | Calculated gaze velocity |
ivt_sample_type |
Fixation | Sample classification |
ivt_event_type |
Fixation | Event-level classification |
ivt_event_index |
5 | Which fixation/saccade this belongs to |
Practical Tips
1. Use Shifted Valid Window
Always include these flags:
--shifted-valid-window --shifted-valid-fallback shrink
Prevents NaN velocities when a validity code changes in the middle of a window.
2. Use Auto Window Conversion
Instead of manually calculating samples, let the tool convert for you:
--window 20 --auto-fixed-window-from-ms # Converts 20 ms to samples based on your data
Adapts to your actual sampling rate automatically.
3. Median Smoothing for Real Data
Real eye-trackers are noisy. Median filter helps:
--smoothing median_strict --smooth-window-samples 3
4. Post-Process to Clean Up
Always merge close fixations and discard short ones:
--merge-close-fixations --merge-fix-max-gap-ms 75 --merge-fix-max-angle-deg 0.5 \
--discard-short-fixations --min-fixation-duration-ms 60
This reduces fragmentation from noise.
5. Validate with Ground Truth
If you have labeled data, run with --evaluate:
python -m ivt_filter.cli --input data.tsv --output out.tsv --classify --evaluate
The evaluation outputs two complementary metrics:
Sample-level (Cohen's Kappa): Agreement per sample. A kappa >0.8 indicates good agreement. Fast to compute, but sensitive to fragmentation — a single misclassified sample can split one ground-truth event into three predicted events, inflating the error count.
Event-level (Maximum IoU, Startsev & Zemblys 2022): Each ground-truth event is matched 1-to-1 to the best-overlapping predicted event by Intersection-over-Union. Provides:
- Event confusion matrix (GT class → matched Pred class or FN)
- False positives (predicted events with no GT match)
- Timing quality per class: mean onset and offset deviation in ms
Use event-level metrics when fragmentation is a concern or when you care about how well the filter boundaries align with ground truth in time.
Common Issues & Fixes
Getting NaN Velocities?
Cause: Not enough valid samples in window or validity codes changing.
Fix:
--shifted-valid-window --shifted-valid-fallback shrink
Phantom Velocities at Gap Edges?
Cause: When one eye drops out briefly and you use --eye average, the sudden switch between one-eye and two-eye averages creates artificial velocity spikes.
Fix:
--tobii-eye-offset-interpolation
Reconstructs the missing eye using the last known left-to-right offset, keeping the averaged position smooth across short outages.
Results Don't Match Tobii Pro Lab?
Tobii Pro Lab uses tobii_gaze_dir with specific windowing. Match it:
--velocity-method tobii_gaze_dir --fixed-window-samples 3 --smoothing none
For the classic Olsen 2D match:
--velocity-method olsen2d --fixed-window-samples 3 --smoothing none
Too Many Micro-Fixations?
Increase minimum fixation duration or smoothing:
--discard-short-fixations --min-fixation-duration-ms 100 \
--smoothing median_strict --smooth-window-samples 5
Full Example Workflow
Here's a complete real-world example:
#!/bin/bash
INPUT="raw_eyetracking.tsv"
OUTPUT="eyetracking_classified.tsv"
# Classify with recommended settings
python -m ivt_filter.cli \
--input "$INPUT" \
--output "$OUTPUT" \
--eye average \
--velocity-method tobii_gaze_dir \
--window 20 \
--auto-fixed-window-from-ms \
--threshold 30 \
--smoothing median_strict \
--smooth-window-samples 3 \
--tobii-eye-offset-interpolation \
--shifted-valid-window \
--shifted-valid-fallback shrink \
--classify \
--merge-close-fixations \
--merge-fix-max-gap-ms 75 \
--merge-fix-max-angle-deg 0.5 \
--discard-short-fixations \
--min-fixation-duration-ms 60 \
--time-column time_us \
--time-unit us \
--with-events
echo "Classification complete. Saved to $OUTPUT"
# Quick statistics
python3 << 'EOF'
import pandas as pd
df = pd.read_csv("$OUTPUT", sep='\t')
fixations = (df['ivt_event_type'] == 'Fixation').sum()
saccades = (df['ivt_event_type'] == 'Saccade').sum()
print(f"Fixations: {fixations}")
print(f"Saccades: {saccades}")
EOF
Docker Usage
Build a minimal runtime image and run the CLI inside the container.
# Build locally
docker build -t ivt-filter:latest .
# Run with a mounted data folder
docker run --rm \
-v "$(pwd)/data:/data" \
ivt-filter:latest \
--input /data/input.tsv \
--output /data/output.tsv \
--eye average \
--velocity-method tobii_gaze_dir \
--window 20 --auto-fixed-window-from-ms \
--threshold 30 \
--smoothing median_strict --smooth-window-samples 3 \
--tobii-eye-offset-interpolation \
--shifted-valid-window --shifted-valid-fallback shrink \
--classify --with-events \
--time-column time_us --time-unit us
Notes:
- The container entrypoint is
python -m ivt_filter.cli; pass flags as shown. - Mount input/output via
-vto persist results on the host. - For GHCR, images are published under
ghcr.io/<owner>/tobii-i-vt-filter-reconstruction.
Architecture
The code is organized by processing stage:
ivt_filter/
├── preprocessing/ # Gap fill, eye selection, smoothing
├── processing/ # Velocity calculation, classification
├── postprocessing/ # Merge fixations, discard short ones
├── evaluation/ # Metrics against ground truth (sample-level + event IoU)
├── config/ # Configuration management
├── strategies/ # Algorithm implementations
└── utils/ # Helper functions
Most users just need the CLI (python -m ivt_filter.cli). Developers can import modules directly:
from ivt_filter import compute_olsen_velocity, apply_ivt_classifier
from ivt_filter.postprocessing.merge_fixations import merge_adjacent_fixations
from ivt_filter.evaluation.event_iou import compute_event_iou_metrics
# ... use functions directly
References
- Olsen, A. (2012). The Tobii I-VT Fixation Filter
- Salvucci, D. D., & Goldberg, J. H. (2000). Identifying fixations and saccades in eye-tracking protocols
- Startsev, M., & Zemblys, R. (2022). Evaluating eye movement event detection: A review of the state of the art. Behavior Research Methods, 54, 1653–1714. https://doi.org/10.3758/s13428-021-01763-7
License
MIT License. See LICENSE for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tobii_ivt_filter-1.0.0.tar.gz.
File metadata
- Download URL: tobii_ivt_filter-1.0.0.tar.gz
- Upload date:
- Size: 140.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce7e1b6207cd71690481846965450147072b8c429dd5d6404805acf410b29fa9
|
|
| MD5 |
deed48059338035ca69bcfa7a126ce70
|
|
| BLAKE2b-256 |
2c1c4891d19dd7038d937516cd8a6c802f8ae9b4a8aedda313acdc828cd32505
|
File details
Details for the file tobii_ivt_filter-1.0.0-py3-none-any.whl.
File metadata
- Download URL: tobii_ivt_filter-1.0.0-py3-none-any.whl
- Upload date:
- Size: 115.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7b73645d33403bef1a5d0731c08b33df8ce4725eb007366cc073a79147712b96
|
|
| MD5 |
e23608f4c626d1e551055f316ef6f4e6
|
|
| BLAKE2b-256 |
cb599bbe3db2bb420c590be5a8e28bb69995ddb8fcc62604149936c4422a540e
|