Quick check: put the pointer on the dot, and one limit for learning

After the quick check's capture, in gaze mode, the dot stays where it is in the
room (the panel's new "lock") and the gaze has the pointer again. You put the
pointer on the dot as you'd correct a click, with the mouse or Meta+J and the
head. That click clicks nothing: the helper sends it as "calverify", learned
whatever its size. Over POINTER_GAZE_NUDGE_MAX, the capture runs again, up to
3 times. A right click or Meta+K skips it.

POINTER_GAZE_NUDGE_MAX is now the one limit: 15 degrees by default (was 8),
and ft-gazed's own limits (8 for SteamVR's tracker, 25 for ours) follow it. A
keyboard correction past it isn't learned but opens a quick check ("recheck"),
in place of the 30 degree keyboard limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-01 10:41:52 -06:00
1 parent cbad7bc470
commit 362968198b
9 files changed
+180 -55

No files matched your search

+21 -13
View File
@@ -44,9 +44,11 @@ click, it sends "lesson <raw yaw> <raw pitch> <true yaw> <true pitch>": where th
was when the mouse took over, and where the pointer was when you clicked (you were looking
there). The gap is the tracker's error there. The raw gaze is the one sent, so it also says
when that look was (the history of what was sent), and so what each eye read then:
- SteamVR: each eye learns its own error, unless the gaze was more than LESSON_MAX degrees
- SteamVR: each eye learns its own error, unless the gaze was more than
POINTER_GAZE_NUDGE_MAX degrees (frametop.conf, 15 by default; the helper's limit too)
past the correction (then it wasn't a nudge onto what you looked at);
- our tracker: the look goes to it as a click ("click T YAW PITCH" on @ft_eyes), as the
- our tracker: unless it was more than POINTER_GAZE_NUDGE_MAX off, the look goes to it as
a click ("click T YAW PITCH" on @ft_eyes), as the
probe's clicks do, and it learns how far the headset has moved on your face. That's
what it gets wrong, and after the headset was off, the first click resets it;
- either way, how far off each eye was (before the lesson taught it anything) goes to the
@@ -70,6 +72,8 @@ tracker's shared memory read-only.
Control socket: abstract unix datagram "@ft_gazed":
lesson <rhy> <rhp> <thy> <thp> from the pointer helper (see above)
calverify <rhy> <rhp> <thy> <thp> the same, the quick check's second step (gazecheck.py)
recheck <deg> a keyboard correction past POINTER_GAZE_NUDGE_MAX: a quick check
status reply: one JSON object
forget drop what the lessons taught (the calibration stays)
reload read calibration.json and the settings again
@@ -122,8 +126,7 @@ SOURCES = ("action", "mmap1", "mmap2", "left", "right") # the ones with a calib
SIDES = ("left", "right") # ft-gaze's order, and the sources for each eye alone
TRACKERS = ("steam", "own")
BIASES = ("auto", "left", "right")
LESSON_MAX = 8.0 # degrees past the correction
OWN_LESSON_MAX = 25.0 # our tracker: after the headset was off, its first clicks can be 10-17 off
NUDGE_MAX = 15.0 # degrees: POINTER_GAZE_NUDGE_MAX's default, the largest lesson taken
HISTORY = 12.0 # seconds of the gaze sent, to find a lesson's look (the helper sends it up to 10 s later)
LOOK = 0.3 # seconds of samples before that moment make the look (the probe's fixation)
RETRY = 3.0 # seconds before starting ft-gaze again
@@ -150,7 +153,7 @@ def log(msg):
def read_settings():
"""(tracker, eye bias) from frametop.conf, defaults for anything missing or unknown."""
"""(tracker, eye bias, nudge max) from frametop.conf, defaults for anything missing or unknown."""
conf = {}
try:
for line in CONF.read_text().splitlines():
@@ -162,7 +165,11 @@ def read_settings():
pass
tracker = conf.get("GAZE_TRACKER", "steam")
bias = conf.get("GAZE_EYE", "auto")
return tracker if tracker in TRACKERS else "steam", bias if bias in BIASES else "auto"
try:
nudge = min(max(float(conf.get("POINTER_GAZE_NUDGE_MAX", NUDGE_MAX)), 1.0), 30.0) # the helper's range
except ValueError:
nudge = NUDGE_MAX
return tracker if tracker in TRACKERS else "steam", bias if bias in BIASES else "auto", nudge
def mtime(path):
@@ -177,7 +184,7 @@ class Service:
self.override, self.verbose, self.to = source, verbose, to
self.source = source or "mmap1" # the older path's source
STATE.mkdir(parents=True, exist_ok=True)
self.tracker, self.bias = read_settings()
self.tracker, self.bias, self.nudge_max = read_settings()
self.conf_mtime = mtime(CONF)
self.models = {name: Correction() for name in SOURCES}
self.mode = DEFAULT_MODEL
@@ -238,7 +245,7 @@ class Service:
def load_settings(self):
self.conf_mtime = mtime(CONF)
tracker, bias = read_settings()
tracker, bias, self.nudge_max = read_settings()
if (tracker, bias) != (self.tracker, self.bias):
log(f"tracker {tracker}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
self.tracker, self.bias = tracker, bias
@@ -313,16 +320,18 @@ class Service:
eyes.append((statistics.median(e[0] for e in seen), statistics.median(e[1] for e in seen)) if seen else None)
return t, eyes
def lesson(self, rhy, rhp, thy, thp):
def lesson(self, rhy, rhp, thy, thp, limit=True):
"""Learn a lesson; limit=False takes it whatever its size (the quick check's own)."""
kind = self.kind
limit = self.nudge_max if limit else math.inf
rec = {"time": time.time(), "kind": kind, "raw": [rhy, rhp], "true": [thy, thp], "wear": self.steam.worn()}
if kind == "source":
dy, dp = thy - rhy, thp - rhp # the whole error there
cy, cp = self.correction(self.source, rhy, rhp)
left = math.hypot(dy - cy, dp - cp)
rec.update(source=self.source, model=self.mode, correction=[cy, cp], lesson_deg=left)
if left > LESSON_MAX:
rec["refused"] = f"more than {LESSON_MAX} deg past the correction"
if left > limit:
rec["refused"] = f"more than {limit} deg past the correction"
else:
self.lives[self.source].add({"time": rec["time"], "hy": rhy, "hp": rhp, "dy": dy, "dp": dp,
"wy": 1.0, "wp": 1.0, "how": "pointer"}, self.models[self.source], self.mode)
@@ -333,7 +342,6 @@ class Service:
weights = self.weights[self.tracker if kind == "own" else "steam"]
rec.update(tracker=self.tracker if kind == "own" else "steam", bias=self.bias, lesson_deg=left, look_t=t,
eyes=eyes, weights=[round(w, 3) for w in weights.weights()])
limit = OWN_LESSON_MAX if kind == "own" else LESSON_MAX
if t is None:
rec["refused"] = "that gaze isn't in the last few seconds sent"
elif left > limit:
@@ -667,7 +675,7 @@ class Service:
elif words[:1] == ["forget"]:
self.forget_lessons()
reply = "ok"
elif words[:1] and words[0] in ("quickcal", "calibrate", "calaccept", "calquit"):
elif words[:1] and words[0] in ("quickcal", "calibrate", "calaccept", "calquit", "calverify", "recheck"):
reply = self.checks.command(words)
elif words[:1] == ["eyes"] and len(words) == 2:
try: