mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 06:00:09 +02:00
Gaze: install our eye tracker, prefer it, and say why a calibration dot wasn't taken
A user on a fresh install got "Calibration failed: only 0 of 21 dots" with no reason. The installer never installed our tracker, so gaze used SteamVR's, and the only way SteamVR's tracker rejects a dot is losing an eye for most of the look. The panel just showed a red ring. - install.sh: step 9/10 installs our tracker (gaze/tracker/install.sh) after gaze mode, yes by default; it needs sudo, so --yes runs it only when sudo won't prompt. If it fails, gaze keeps SteamVR's tracker. Configs that still say GAZE_TRACKER=steam (the old template) are asked whether to switch. - GAZE_TRACKER=auto, the new default: ours when it's installed (the frame grabber, its unit, and ft-eyes' Python), else SteamVR's. ft-gazed rechecks every second, so installing it switches over. Input Settings lists Own tracker first as recommended, and says how to install it when it's missing (checking the host's /etc through /run/host from the dev container). - The calibration panel has a note line, orange over the instructions: why a dot wasn't taken (an eye lost, a blink, the eyes disagreeing for SteamVR's tracker, from steady_samples' new drop counts; ft-eyes' reply for ours), what a click is still waiting for after 1.5 s, and a failed calibration's most common reason, which the Gaze page shows too. steady_samples keeps the same samples as before (checked on 2037 windows of recordings); a lost eye is named before a blink, since its openness reads 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a7f9a8dd80
commit
12ad93d24c
12 files changed
+252
-63
No files matched your search
+89
-9
@@ -37,7 +37,10 @@ works however far off the tracker is; a left click or Meta+J (the pointer helper
|
||||
"calaccept") takes it now. The full calibration's and five's dots wait for that click: you
|
||||
click when you're looking at the dot (the user asked for that: a steady gaze isn't always on
|
||||
the dot), and the gaze held still up to then is taken (ACCEPT_SPREAD). They wait as long as
|
||||
it takes, up to CLICK_IDLE. A right click or Meta+K ("calquit") closes the panel. The pointer hides meanwhile ("calpanel 1",
|
||||
it takes, up to CLICK_IDLE. A dot not taken says why in the panel's note line (reject_reason:
|
||||
gazecal.steady_samples' drop counts for SteamVR's tracker, ft-eyes' reply for ours), as does a
|
||||
click with nothing taken after ACCEPT_WAIT, and a failed calibration names its most common
|
||||
reason there and in the status. A right click or Meta+K ("calquit") closes the panel. The pointer hides meanwhile ("calpanel 1",
|
||||
renewed every second; the helper shows it again by itself when that stops).
|
||||
|
||||
What a capture teaches:
|
||||
@@ -121,6 +124,42 @@ def check_dots(kind, own):
|
||||
return out
|
||||
|
||||
|
||||
# Why SteamVR's samples for a look were dropped (gazecal.steady_samples' counts) -> (short, long):
|
||||
# short for the small panel, long for the calibration's. FIT_HINT goes after the ones the
|
||||
# headset's fit causes, in the calibration's panel.
|
||||
STEAM_REASONS = {"lost_left": ("left eye lost", "SteamVR lost your left eye"),
|
||||
"lost_right": ("right eye lost", "SteamVR lost your right eye"),
|
||||
"lost_both": ("both eyes lost", "SteamVR lost both eyes"),
|
||||
"blink": ("blinked", "you blinked"),
|
||||
"vergence": ("eyes disagreed", "SteamVR's two eyes disagreed")}
|
||||
FIT_HINT = "check the headset fit"
|
||||
ACCEPT_WAIT = 1.5 # seconds after a click with no capture before the panel says what it waits for
|
||||
|
||||
|
||||
def reject_reason(reply, why):
|
||||
"""Why a dot wasn't taken -> (short, long, fit): our tracker's reply (`reply`), or SteamVR's
|
||||
drop counts (`why`, when `reply` is None). `fit`: the headset's fit is the likely cause."""
|
||||
if reply is None:
|
||||
if not why:
|
||||
return "no reading", "SteamVR sent no reading for that look", False
|
||||
key = max(why, key=why.get)
|
||||
return *STEAM_REASONS[key], key.startswith("lost")
|
||||
words = reply.removeprefix("fail ").split()
|
||||
# ft-eyes: "fail the left eye was seen in only 3 frames", "fail the left eye moved (4.2 px)"
|
||||
if len(words) >= 3 and words[0] == "the" and words[2] == "eye":
|
||||
eye = words[1]
|
||||
if "seen" in words:
|
||||
return f"{eye} eye not seen", f"our tracker saw your {eye} eye in only {words[-2]} frames", True
|
||||
if "moved" in words:
|
||||
return f"{eye} eye moved", f"your {eye} eye moved while you looked", False
|
||||
if not reply:
|
||||
return "no answer", "our tracker didn't answer", False
|
||||
if reply.startswith("our tracker isn't running"):
|
||||
return "tracker not running", "our tracker isn't running", False
|
||||
text = reply.removeprefix("fail ")
|
||||
return text[:24], f"our tracker said: {text}", False
|
||||
|
||||
|
||||
def spread(points):
|
||||
"""The median point and the spread around it (1.4826 x the median distance: a standard
|
||||
deviation that one stray sample can't move far)."""
|
||||
@@ -174,6 +213,7 @@ class Checks:
|
||||
self.full_armed = True # gaze mode on without a calibration opens the full one (need_full)
|
||||
self.full_blocked = None # why it can't open now
|
||||
self.full_retry_at = 0.0
|
||||
self.full_failed = None # why the last calibration failed (too few dots), for problem()
|
||||
self.last_quick = 0.0
|
||||
self.sample_at = 0.0 # the tracker last sent anything
|
||||
self.seen_at = 0.0 # eyes last seen (SteamVR's tracker's variance for them, "unc")
|
||||
@@ -327,6 +367,8 @@ class Checks:
|
||||
if self.full_blocked:
|
||||
return f"Not calibrated, and the calibration can't open: {self.full_blocked}"
|
||||
if not self.full_armed:
|
||||
if self.full_failed:
|
||||
return f"Not calibrated: the calibration failed (most dots: {self.full_failed}). Use Calibrate"
|
||||
return "Not calibrated: the calibration closed unfinished. Use Calibrate"
|
||||
return "Not calibrated: the calibration opens in the headset"
|
||||
|
||||
@@ -360,7 +402,7 @@ class Checks:
|
||||
now = time.monotonic()
|
||||
self.check = {"kind": kind, "reason": reason, "own": own, "dots": check_dots(kind, own), "i": 0,
|
||||
"started": now, "shown": now, "run": [], "accept": False, "done_at": None, "tries": 0,
|
||||
"skipped": 0, "captured": 0, "points": {}}
|
||||
"skipped": 0, "captured": 0, "points": {}, "reasons": {}, "fit_reasons": set(), "note": ""}
|
||||
log(f"{kind} check: {reason}")
|
||||
if kind == "full":
|
||||
st = ask(SCREENS, "state", 0.5).split()
|
||||
@@ -444,7 +486,7 @@ class Checks:
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} look")
|
||||
self.last_progress = None
|
||||
c["shown"] = time.monotonic()
|
||||
c["run"], c["accept"], c["done_at"] = [], False, None
|
||||
c["run"], c["accept"], c["done_at"], c["accept_at"] = [], False, None, None
|
||||
|
||||
def on_sample(self, s):
|
||||
self.sample_at = time.monotonic()
|
||||
@@ -470,6 +512,7 @@ class Checks:
|
||||
if "hy" not in src:
|
||||
return
|
||||
now = time.monotonic()
|
||||
c["gaze_at"] = now
|
||||
if now < c["shown"] + CHECK_SETTLE:
|
||||
return
|
||||
g = (src["hy"], src["hp"])
|
||||
@@ -530,7 +573,9 @@ class Checks:
|
||||
if ok:
|
||||
svc.weights["own"].add(miss)
|
||||
else:
|
||||
steady = steady_samples(samples)
|
||||
why = {}
|
||||
steady = steady_samples(samples, why=why)
|
||||
rec["dropped"] = why
|
||||
reads = {}
|
||||
for name in ("action", "mmap1", "mmap2", "left", "right"):
|
||||
pts = [(smp["src"][name]["hy"], smp["src"][name]["hp"]) for smp in steady
|
||||
@@ -564,23 +609,41 @@ class Checks:
|
||||
if svc.kind == "eyes":
|
||||
svc.weights["steam"].add(miss)
|
||||
svc.dirty = True
|
||||
if not ok:
|
||||
short, long, fit = reject_reason(rec.get("reply", "") if c["own"] else None, rec.get("dropped"))
|
||||
rec["reason"] = long
|
||||
c["reasons"][long] = c["reasons"].get(long, 0) + 1
|
||||
if fit:
|
||||
c["fit_reasons"].add(long)
|
||||
rec["accepted"] = ok
|
||||
self.log_check(rec)
|
||||
if not ok:
|
||||
c["tries"] += 1
|
||||
log(f"{c['kind']} check dot {c['i'] + 1}: not taken ({rec.get('reply', '')})")
|
||||
if c["tries"] >= 2 or c["kind"] != "full":
|
||||
log(f"{c['kind']} check dot {c['i'] + 1}: not taken: {rec['reason']} ({rec.get('reply', '')})")
|
||||
full = c["kind"] == "full"
|
||||
if c["tries"] >= 2 or not full:
|
||||
self.note(f"Dot skipped: {long}" + (f" ({FIT_HINT})" if fit else "") if full else f"Skipped: {short}")
|
||||
self.skip()
|
||||
else:
|
||||
self.note(f"Not taken: {long}. Look at the dot and click again")
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} fail")
|
||||
c["run"], c["accept"] = [], False
|
||||
c["run"], c["accept"], c["accept_at"] = [], False, None
|
||||
c["shown"] = time.monotonic() # settle again, then retry
|
||||
return
|
||||
c["captured"] += 1
|
||||
c["tries"] = 0
|
||||
self.note("")
|
||||
self.to_panel(f"dot {yaw:.3f} {pitch:.3f} done")
|
||||
c["done_at"] = time.monotonic() + DONE_PAUSE
|
||||
|
||||
def note(self, text):
|
||||
"""The panel's warning line, over the instructions (empty: none). It stays until the
|
||||
next dot is taken, so a skipped dot's reason is still there at the one after it."""
|
||||
c = self.check
|
||||
if c.get("note") != text:
|
||||
c["note"] = text
|
||||
self.to_panel(f"note {text}".rstrip())
|
||||
|
||||
def skip(self):
|
||||
c = self.check
|
||||
yaw, pitch, _ = c["dots"][c["i"]]
|
||||
@@ -609,12 +672,20 @@ class Checks:
|
||||
return
|
||||
n = len(c["dots"])
|
||||
if c["captured"] < n * 2 / 3:
|
||||
log(f"calibration failed: only {c['captured']} of {n} dots; the old one stays")
|
||||
reasons = c["reasons"]
|
||||
main = max(reasons, key=reasons.get) if reasons else None
|
||||
self.full_failed = main
|
||||
log(f"calibration failed: only {c['captured']} of {n} dots; the old one stays"
|
||||
+ (f". Not taken: {', '.join(f'{r} ({k})' for r, k in reasons.items())}" if reasons else ""))
|
||||
self.to_panel(f"text Calibration failed: only {c['captured']} of {n} dots. Try again from Input Settings")
|
||||
if main:
|
||||
fit = main in c["fit_reasons"]
|
||||
self.note(f"Most dots: {main}" + (". Check headset fit on the Gaze page" if fit else ""))
|
||||
self.to_panel("dot 0 0 off")
|
||||
c["done_at"] = time.monotonic() + 3.0
|
||||
c["done_at"] = time.monotonic() + (8.0 if main else 3.0) # time to read why
|
||||
c["closing"] = True
|
||||
return
|
||||
self.full_failed = None
|
||||
if c["own"]:
|
||||
reply = ask(EYES, "calib-fit", 10.0)
|
||||
log(f"calibration ({c['captured']} of {n} dots): our tracker says {reply or 'nothing'}")
|
||||
@@ -684,6 +755,8 @@ class Checks:
|
||||
if self.check and self.check["kind"] == "fit":
|
||||
self.check["fit"].toggle_guide(time.monotonic())
|
||||
elif self.check:
|
||||
if not self.check["accept"]:
|
||||
self.check["accept_at"] = time.monotonic()
|
||||
self.check["accept"] = True
|
||||
return "ok"
|
||||
if cmd == "calquit":
|
||||
@@ -730,6 +803,13 @@ class Checks:
|
||||
return
|
||||
if c["done_at"]:
|
||||
return
|
||||
if c["accept"] and c["accept_at"] and now - c["accept_at"] > ACCEPT_WAIT:
|
||||
# Clicked, but no capture yet (see on_sample): say what it's waiting for.
|
||||
full = c["kind"] == "full"
|
||||
if now - c.get("gaze_at", 0.0) > 0.5:
|
||||
self.note("Waiting: the eye tracker isn't sending a gaze" if full else "Waiting: no gaze")
|
||||
else:
|
||||
self.note("Waiting for your gaze to hold still on the dot" if full else "Hold your look still")
|
||||
if c["kind"] == "quick" and now - c["started"] > QUICK_TIMEOUT:
|
||||
self.close("ignored")
|
||||
elif c["kind"] != "quick" and now - c["shown"] > CLICK_IDLE:
|
||||
|
||||
Reference in new issue
Block a user