frametop

Gaze (experimental)

The Steam Frame’s eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it.

Day to day, install the gaze service, then turn gaze mode on and calibrate on the Gaze page of Frametop Input Settings (Calibrate). The installer offers the gaze service (gaze/run.sh install) and then our own tracker (gaze/tracker/install.sh), which gaze mode uses once it’s installed; the probe is installed by hand.

gaze/run.sh install           # the gaze service: builds ft-gaze and the panel, starts with SteamVR
gaze/ft-gazectl on            # the pointer follows your gaze (off: the mouse alone)
gaze/tracker/install.sh       # our own eye tracker's frame grabber (asks for sudo)
gaze/build.sh                 # build ft-gaze and the panel by hand
gaze/probe/install.sh         # development: build, and add Frametop Gaze Probe to the app menu
gaze/probe/ft-gazeprobe --screen 1
scripts/gaze-report.py        # why gaze or its calibration doesn't work, with what looks wrong first

Gaze pointer

Gaze as an input method for the whole desktop, without replacing anything of SteamVR’s:

Lessons are logged to pointer-lessons.jsonl: the raw gaze, the true direction, the correction at the time, and how far off it was.

Gaze sources

Source Where it comes from
SteamVR action An eyetracking action bound to /user/head/eyetracking (actions/), read with IVRInput::GetEyeTrackingDataRelativeToNow. This is the supported way.
mmap set 1, set 2 /dev/shm/eye-server.mmap, which SteamVR’s eyetracking process writes for the HMD driver (driver_cv.so). It has two sets of per-eye directions in head space: set 1 is filtered, and its two eyes always share one pitch; set 2 is each eye’s own reading. After each set come the tracker’s variances for each eye, and at the end each eye’s raw measurement and its variance (the tracker’s confidence in that frame), which ft-gaze passes on for the fit check.
Left eye, right eye Each eye alone, from set 2: calibrate and test them to see what one eye is worth against both. The layout is undocumented (offsets are in ft-gaze.cpp) and may change with a SteamVR update. ft-gaze maps it read-only; the file also carries calibration clicks to the tracker and must never be written.
Own tracker Our own tracker (tracker/, experimental; see “Our own eye tracker”). It keeps its own calibration, not SteamVR’s: Calibrate on the Gaze page fits it while Eye tracker is Own tracker (eight dots to a ring instead of six, on a slight oval, since the fit goes wrong past its dots; the probe’s calibration with its tracker toggle on Own tracker does the same), and clicks teach it how far the headset has moved on your face since. After the headset was off, one look at a centre dot (the quick check, or the probe’s first dot) resets that. With Own tracker on, the probe hides SteamVR’s gaze and draws a red dot where each eye alone puts it, and asks the gaze service to keep the tracker running. The gaze pointer can use it too (Eye tracker: Own tracker, on the Gaze page of Frametop Input Settings). ft-gaze reports it as own while it’s running, and as {"ok":0} otherwise.

The tracker stops when the headset is off your head. SteamVR also calibrates gaze on its own from laser-mouse clicks, treating each click as a spot you were looking at. That includes mouse clicks through the Frametop pointer, so a click where the pointer’s dot isn’t what you’re looking at teaches SteamVR a wrong sample (it only takes clicks within 5 degrees of your gaze). In the probe, use Enter or Space as the trigger: keys don’t go through SteamVR’s laser. See Accept usercal in ~/.local/share/Steam/logs/eyetracking.txt. When the tracker loses an eye, the same log says CEyePoseUKF L: Large dt (or R) as it starts that eye over.

Our own eye tracker

gaze/tracker/ is an eye tracker of our own, because SteamVR’s is about 1.5 degrees off after the best correction the gaze service can learn, and what’s left is mostly look-to-look noise that no correction on top of its output can remove. Ours processes the eye cameras itself: 0.59 degrees (median) in its best live session against 0.83 for SteamVR’s with the probe’s correction, and after the headset was taken off and put back without recalibrating, 0.58 once your first clicks had taught it where the headset sat (tracker/findings.md has the measurements).

Ground rules, for anyone changing it:

Headset fit

Check headset fit on the Gaze page opens it in the headset panel: a card per eye (tracked or lost, the tracker’s signal, how much of the last 10 s it was seen) and the hints, live while you adjust the headset. A left click or Meta+J runs the guided check (dots, then looks down, up, left and right), and a right click or Meta+K closes it. The probe’s Headset fit mode (ft-gazeprobe --mode fit, for development) has the same check with maps: it shows, for each eye, whether the tracker has it, how open it is, and the tracker’s confidence in it, and a map of where you looked coloured by how often it lost that eye there. Hints under the maps say which eye gets lost where, and what to try. Enter runs a guided check: dots around the screen, then looking down at the keyboard, up, left and right. R starts over. Adjust the headset while you watch it.

Losing an eye is usually about where you look, not the tracker. On this Frame the left eye was lost 57 to 64 % of the time looking 30 to 50 degrees down (at the keyboard) and the right eye never; at screen height both were seen over 98 % of the time. Looking down, the lids come down over the eyes. That’s harmless, since the gaze service ignores looks down past the screens: they show on the maps, but not in the counts or as a problem.

Probe

The probe is a development tool (in the Gaze page’s overflow menu): calibration experiments, accuracy tests, and practice modes. Users calibrate and check in the headset panel instead.

The trigger is Enter, Space, or a mouse button. Right-click anywhere in the window (or press the Menu key or Shift+F10) for a menu with Run calibration, Start accuracy test, Calibrate from last test, Reset calibration, the modes, the panel, fullscreen, and Quit. The buttons at the top right show and hide the panel, leave fullscreen, and quit. The arrow in the panel’s title bar collapses it to just that bar, so the dot and targets behind it stay visible; the collapsed bar stays through tests. The keys do the same (Tab, C, F11, Esc), but only after you click the window once, since Frametop sends typing to the panel you clicked last. If ft-gaze stops, the probe starts it again after 3 s and shows why it stopped. Windowed mode stays on the screen it was on, and a small KWin script tells the probe where the window is, so the dot and targets are still in the right place.

The default trigger is freeze and look, the on-demand calibration. The press freezes the dot where the tracker says you’re looking. Then look at the frozen dot: it’s a target right where you’re looking, and it stays put. After settle ms it averages the unsmoothed gaze for capture ms, or until you let go if you hold longer. The gap between the frozen dot and that average is the tracker’s error at that spot, and the calibration learns it. The live dot is hidden while frozen so it can’t pull your eye (the “Live dot while frozen” option shows it anyway). Freeze and look drops blink and dropout samples and uses medians, like the calibration run. A capture is thrown out if the gaze spread more than max spread (1 degree) or the error is over max error (12 degrees; the real error reaches 8-9 degrees looking well up or down). Each capture is logged to captures.jsonl.

The older triggers, nudge with head and nudge with eyes, are still there. Hold, then move the frozen dot onto what you meant with your head or eyes (nudge gain scales the movement), and release. When nudging with your eyes, don’t look at the dot: it follows your gaze, error included, so it runs away.

Smoothing defaults to fixation lock. It holds the dot on the running mean of the current fixation and jumps when your gaze leaves the fixation radius. One Euro follows more smoothly, and its beta is per degree a second. Sitting still, raw gaze jitters by about 0.25-0.3 degrees, and mmap set 2 was the quietest source, so it’s the default.

Click corrections (“Learn from clicks”, on by default) are learned on the fly from snap and practice clicks, on top of the calibration. Right-click, Clear click corrections forgets them and keeps the calibration. The first click shifts the whole correction. More clicks bend it (the same quadratic terms, held near zero except the offset), and what’s left near each click is added within about 3 degrees of it: on your data, errors less than 3 degrees apart are alike, and ones further apart aren’t. Recent clicks count more, so it follows SteamVR’s gaze as that moves. A big element only weakly says where on it you looked, so a wide list row barely counts sideways. Replayed on logged points, a calibration from an earlier session was 4.95 degrees off; one click brought that to 2.3, five to 1.6, and twenty to 1.2. They’re saved with the calibration and start over with a new calibration run.

SteamVR’s eye tracker also calibrates itself, from clicks (Accept usercal in ~/.local/share/Steam/logs/eyetracking.txt). It takes a quick mouse-button down and up as “you were looking there”, if the gaze was held within 5 degrees of the click. In the logs, the accepted clicks were all under 0.14 s, one of 0.38 s was “too slow”, and a click that moved between down and up was refused. It keeps that inside the running eyetracking process and saves nothing, so when SteamVR starts again, its calibration starts over and the raw gaze moves: your 13:39 and 21:23 sessions had a restart between them, and the error’s shape changed, not just its offset. The panel shows when the eye tracker started, whether that was after your calibration, and how many clicks it has learned from since. Snap and practice clicks are what keep up with it: a drag is too slow for SteamVR to take, and a quick click on the snapped element teaches both calibrations the same spot.

Pick the Correction model in the panel or the right-click menu. Choosing one fits it right away from the latest calibration’s dots and the calibrated-area tests since (points.jsonl), and the choice is saved with the calibration. The models (per source) are none, offset, affine (offset plus a straight-line change across your view), quadratic (the default), and affine+grid or quadratic+grid (plus a 10-degree grid for what’s left). Quadratic is the second-order polynomial video eye trackers usually calibrate with. The Frame’s error grows as your eyes turn away from the centre: it overstates vertical movement, more the further up or down you look, and looking up adds a sideways error. A straight line only follows part of that. A polynomial runs away outside the spots it was fitted on, so the model only follows it to 3 degrees past the range of view it has seen. They’re keyed by where you’re looking relative to your head, and saved in ~/.local/state/frametop/gaze/calibration.json. Freeze captures go to captures.jsonl, nudges to practice.jsonl, and test results to test-*.json in the same folder. Every calibration dot and test target is also added to points.jsonl: where in your view it was, the raw error, the error with the calibration of the time, and the spread. That’s the data for refining, and for finding where the calibration is off.