Use the other eye when the tracker loses one, and add a headset fit check

SteamVR's combined gaze keeps going on one eye, but it holds the lost
eye's yaw, so the gaze moves half as far sideways as the eyes do.
ft-gaze now reads each eye's tracking uncertainty and raw measurement
from eye-server.mmap. When the tracker loses an eye, ft-gazed takes the
gaze from the other one, plus the offset that eye usually shows
against both, learned while both are seen. On a recording, one eye
alone came out a median 0.8 degrees from both eyes' gaze.

Glances down at the keyboard, past every screen, aren't sent. The
pointer stays put, and eyes lost there don't count as lost.

The gaze probe gets a Headset fit mode. It shows per-eye tracking,
openness and confidence, maps where each eye gets lost, gives hints,
and has a guided check. The settings app opens it from the Gaze page
and shows how often each eye is lost. The probe can also test each eye
alone, and its side panel now collapses to a title bar so the dot
isn't hidden behind it.

Snapping to UI elements is deferred; the mouse drag is the correction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-28 22:07:41 -06:00
1 parent e60cb2f28a
commit f01c84f9a3
10 files changed
+715 -68

No files matched your search

+15 -4
View File
@@ -541,9 +541,12 @@ class Backend(QObject):
n = status["samples"] - prev[1]["samples"]
status["rate"] = n / (now - prev[0])
status["one_eye_share"] = (status["one_eye"] - prev[1]["one_eye"]) / n if n else 0.0
for key in ("lost_left", "lost_right"):
if key in status and key in prev[1]:
status[key + "_share"] = (status[key] - prev[1][key]) / n if n else 0.0
elif self._gaze:
status.setdefault("rate", self._gaze.get("rate"))
status.setdefault("one_eye_share", self._gaze.get("one_eye_share"))
for key in ("rate", "one_eye_share", "lost_left_share", "lost_right_share"):
status.setdefault(key, self._gaze.get(key))
if not prev or now - prev[0] > 0.5:
self._gaze_prev = (now, status)
self._gaze = status
@@ -604,13 +607,21 @@ class Backend(QObject):
@Slot()
def openGazeProbe(self):
"""Calibrate in ft-gazeprobe (a GTK app on the host, fullscreen on a Frametop screen)."""
self._open_probe([], "Opening the gaze probe: calibrate there, then close it")
@Slot()
def openHeadsetFit(self):
"""The probe's Headset fit mode: how well the tracker sees each eye, as you adjust."""
self._open_probe(["--mode", "fit"], "Opening the headset fit check in the gaze probe")
def _open_probe(self, args, done):
runner = ["distrobox-host-exec"] if shutil.which("distrobox-host-exec") else []
env = [f"{k}={os.environ[k]}" for k in ("WAYLAND_DISPLAY", "DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS")
if os.environ.get(k)]
try:
subprocess.Popen(runner + ["env"] + env + [GAZE_PROBE], stdin=subprocess.DEVNULL,
subprocess.Popen(runner + ["env"] + env + [GAZE_PROBE] + args, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
self.message.emit("Opening the gaze probe: calibrate there, then close it", False)
self.message.emit(done, False)
except OSError as e:
self.message.emit(f"Couldn't open the gaze probe: {e}", True)