mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
Check headset fit now runs in the headset panel too ("fitcheck"): a card per
eye (tracked or lost, the tracker's signal, how much of the last 10 s it was
seen) and the hints, from the probe's fitcheck.py, updated at most twice a
second; left click or Meta+J runs its guided check, right click or Meta+K closes
it. So Quick check, Calibrate, and Check headset fit all happen in one place.
The gaze probe moves to the Gaze page's overflow menu as "Gaze probe
(development)", and its app menu entry says it's a development tool. Texts that
sent users to it ("use Calibrate… with Own tracker") point at Calibrate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
368 lines
16 KiB
Python
368 lines
16 KiB
Python
"""fitcheck: how well the eye tracker sees each eye, for fitting the headset (the headset
|
|
panel's fit check, gazecheck.py, and ft-gazeprobe's Headset fit mode).
|
|
|
|
From each ft-gaze sample it takes, per eye, whether the tracker has that eye (its variance
|
|
for the eye's direction, "unc", under EYE_LOST; see gazecal), how open the eye is, and the
|
|
tracker's own confidence in its latest measurement of it ("eye" "q": the measurement's
|
|
variance, about 2e-5 on a clear view). It keeps that per direction you look in (10 degree
|
|
cells, and a few named regions), so a map shows where each eye gets lost, and turns it into
|
|
hints.
|
|
|
|
On the Frame this was written for, the left eye was lost 57-63 % of the time looking 30-50
|
|
degrees down (at the keyboard) and the right never; at screen height both were seen over
|
|
98 % of the time. Looking down, the lids come down over the eyes, and a glance at the
|
|
keyboard isn't where the gaze pointer matters: ft-gazed ignores looks down past the
|
|
screens. So those are on the maps, but not in the cards' counts or the hints' warnings.
|
|
|
|
Directions are head-relative degrees (yaw +left, pitch +up), the combined gaze's.
|
|
"""
|
|
|
|
import math
|
|
import statistics
|
|
from collections import deque
|
|
|
|
from gazecal import EYE_FOUND, EYE_LOST
|
|
|
|
EYES = ("Left eye", "Right eye")
|
|
CELL = 10.0
|
|
YAW = (-40, 40)
|
|
PITCH = (-50, 30)
|
|
CLOSED = 0.12 # openness under this: closed (a blink, or squeezed shut)
|
|
MIN_REGION = 60 # samples in a region before it's judged (two thirds of a second)
|
|
|
|
# Named regions, for the hints: (key, words, test on yaw and pitch).
|
|
REGIONS = [
|
|
("down", "down (at a keyboard or desk)", lambda y, p: p < -20),
|
|
("up", "up", lambda y, p: p > 15),
|
|
("left", "to the left", lambda y, p: y > 20 and -20 <= p <= 15),
|
|
("right", "to the right", lambda y, p: y < -20 and -20 <= p <= 15),
|
|
("centre", "straight ahead (screen height)", lambda y, p: abs(y) <= 20 and -20 <= p <= 15),
|
|
]
|
|
|
|
# The guided check: dots on the screen (fractions of its size; the corners stay clear of the
|
|
# probe's title bar and toolbar), then prompts to look past it. Seconds each.
|
|
GUIDE = [
|
|
("dot", (0.5, 0.5), 2.0), ("dot", (0.12, 0.2), 2.0), ("dot", (0.88, 0.2), 2.0),
|
|
("dot", (0.88, 0.92), 2.0), ("dot", (0.12, 0.92), 2.0), ("dot", (0.5, 0.92), 2.0),
|
|
("look", "Look down at your keyboard", 4.0), ("look", "Look up, above the screen", 3.0),
|
|
("look", "Look far to the left", 3.0), ("look", "Look far to the right", 3.0),
|
|
("dot", (0.5, 0.5), 2.0),
|
|
]
|
|
|
|
|
|
class FitCheck:
|
|
def __init__(self):
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.lost = [False, False]
|
|
self.lost_since = [None, None]
|
|
self.losses = [0, 0] # times each eye was lost
|
|
self.durations = [[], []] # how long each loss lasted (s)
|
|
self.cells = [{}, {}] # per eye: (i, j) -> [samples, lost]
|
|
self.regions = [{k: [0, 0] for k, _, _ in REGIONS} for _ in EYES]
|
|
self.recent = [deque(maxlen=900), deque(maxlen=900)] # (t, lost) for the last 10 s
|
|
self.q = [deque(maxlen=180), deque(maxlen=180)] # recent fresh measurement variances
|
|
self.open = [0.0, 0.0]
|
|
self.unc = [0.0, 0.0]
|
|
self.gaze = None
|
|
self.samples = 0
|
|
self.guide = None # {"start": t, "step": i, "results": [...]}
|
|
self.have_eye_data = False
|
|
|
|
# --- Samples ---
|
|
|
|
def feed(self, s, now):
|
|
m1 = s["src"].get("mmap1") or {}
|
|
unc, opens = m1.get("unc"), m1.get("open")
|
|
if "hy" not in m1 or not unc or not opens:
|
|
return
|
|
self.have_eye_data = True
|
|
self.samples += 1
|
|
eye = s.get("eye") or {}
|
|
hy, hp = m1["hy"], m1["hp"]
|
|
self.gaze = (hy, hp)
|
|
self.unc = list(unc)
|
|
for k in (0, 1):
|
|
self.open[k] += 0.2 * (opens[k] - self.open[k])
|
|
was = self.lost[k]
|
|
self.lost[k] = unc[k] > (EYE_FOUND if was else EYE_LOST)
|
|
if self.lost[k] and not was:
|
|
if not looking_down(hy, hp):
|
|
self.losses[k] += 1
|
|
self.lost_since[k] = now
|
|
elif was and not self.lost[k] and self.lost_since[k] is not None:
|
|
if not looking_down(hy, hp):
|
|
self.durations[k].append(now - self.lost_since[k])
|
|
self.lost_since[k] = None
|
|
q = eye.get("q")
|
|
if q and (eye.get("new") or [1, 1])[k]:
|
|
self.q[k].append(q[k])
|
|
closed = [opens[k] < CLOSED for k in (0, 1)]
|
|
if all(closed) or all(self.lost):
|
|
return # a blink: says nothing about the fit
|
|
key = (math.floor(hy / CELL), math.floor(hp / CELL))
|
|
for k in (0, 1):
|
|
c = self.cells[k].setdefault(key, [0, 0])
|
|
c[0] += 1
|
|
c[1] += self.lost[k]
|
|
for rk, _, test in REGIONS:
|
|
if test(hy, hp):
|
|
r = self.regions[k][rk]
|
|
r[0] += 1
|
|
r[1] += self.lost[k]
|
|
if not looking_down(hy, hp):
|
|
self.recent[k].append((now, self.lost[k]))
|
|
g = self.guide
|
|
if g and g["step"] < len(GUIDE):
|
|
res = g["results"][g["step"]]
|
|
res[0] += 1
|
|
res[1] += self.lost[0]
|
|
res[2] += self.lost[1]
|
|
|
|
# --- The guided check ---
|
|
|
|
def toggle_guide(self, now):
|
|
if self.guide and self.guide["step"] < len(GUIDE):
|
|
self.guide = None
|
|
else:
|
|
self.guide = {"start": now, "step": 0, "step_start": now, "results": [[0, 0, 0] for _ in GUIDE]}
|
|
|
|
def guide_step(self, now):
|
|
"""The current step (kind, what, seconds left), or None when there's no check running."""
|
|
g = self.guide
|
|
if not g or g["step"] >= len(GUIDE):
|
|
return None
|
|
kind, what, secs = GUIDE[g["step"]]
|
|
if now - g["step_start"] >= secs:
|
|
g["step"] += 1
|
|
g["step_start"] = now
|
|
return self.guide_step(now)
|
|
return kind, what, secs - (now - g["step_start"])
|
|
|
|
# --- Summaries ---
|
|
|
|
def status(self, k):
|
|
if not self.samples:
|
|
return "no data", (0.6, 0.6, 0.6)
|
|
if self.lost[k]:
|
|
return "LOST", (1.0, 0.35, 0.3)
|
|
if self.open[k] < CLOSED:
|
|
return "closed", (0.8, 0.8, 0.8)
|
|
return "tracking", (0.35, 1.0, 0.5)
|
|
|
|
def tracked_share(self, k, now, window=10.0):
|
|
pts = [lost for t, lost in self.recent[k] if now - t <= window]
|
|
return (1 - sum(pts) / len(pts)) if pts else None
|
|
|
|
def signal(self, k):
|
|
"""The tracker's recent confidence in this eye, 0..1 (from its measurement variance:
|
|
2e-5 or less is 1, 1e-3 or more is 0), or None."""
|
|
if len(self.q[k]) < 10:
|
|
return None
|
|
q = statistics.median(self.q[k])
|
|
return min(1.0, max(0.0, (math.log10(1e-3) - math.log10(max(q, 1e-9))) / (math.log10(1e-3) - math.log10(2e-5))))
|
|
|
|
def region_share(self, k, key):
|
|
n, lost = self.regions[k][key]
|
|
return (lost / n) if n >= MIN_REGION else None
|
|
|
|
def hints(self):
|
|
if not self.have_eye_data:
|
|
return ["No per-eye data from ft-gaze (it needs SteamVR's eye-server.mmap, and a current build)."]
|
|
if self.samples < 3 * MIN_REGION:
|
|
return ["Look around slowly (the screen's corners, then down at your keyboard, up, left and right) "
|
|
"or press Enter for a guided check."]
|
|
out = []
|
|
bad = {}
|
|
for k in (0, 1):
|
|
for key, words, _ in REGIONS:
|
|
share = self.region_share(k, key)
|
|
if share is not None and share >= 0.15:
|
|
bad.setdefault(key, {})[k] = share
|
|
for key, words, _ in REGIONS:
|
|
if key not in bad:
|
|
continue
|
|
eyes = bad[key]
|
|
if len(eyes) == 2:
|
|
if key == "down":
|
|
out.append("Both eyes get lost looking down at the keyboard. That's fine: the gaze service "
|
|
"ignores looks down past the screens.")
|
|
elif key == "centre":
|
|
out.append(f"Both eyes get lost looking {words} ({eyes[0]:.0%} and {eyes[1]:.0%} of the time): "
|
|
"check the lenses are clean and the headset is on as usual; if it stays like this, "
|
|
"the tracker isn't getting a clear view of either eye.")
|
|
else:
|
|
out.append(f"Both eyes get lost looking {words}: that's past what the tracker covers for your "
|
|
"face, not one eye's fit.")
|
|
continue
|
|
k = next(iter(eyes))
|
|
other = self.region_share(1 - k, key)
|
|
vs = f", the {EYES[1 - k].lower()} {other:.0%}" if other is not None else ""
|
|
line = f"{EYES[k]}: lost {eyes[k]:.0%} of the time looking {words}{vs}."
|
|
if key == "down":
|
|
line += (" That's fine: glancing at the keyboard, the lids come down over the eyes, and the gaze "
|
|
"service ignores looks down past the screens, so the pointer stays put.")
|
|
elif key == "centre":
|
|
line += (" Even at screen height: clean that lens, and check its distance from your eye and the "
|
|
"IPD. Lashes that touch the lens get in the camera's way too.")
|
|
else:
|
|
line += (" At the edge of your view: try the IPD setting, and centring the headset between your "
|
|
"eyes.")
|
|
out.append(line)
|
|
s0, s1 = self.signal(0), self.signal(1)
|
|
if s0 is not None and s1 is not None and abs(s0 - s1) > 0.25:
|
|
k = 0 if s0 < s1 else 1
|
|
out.append(f"The tracker is less sure of your {EYES[k].lower()} even when it has it "
|
|
f"(signal {min(s0, s1):.0%} against {max(s0, s1):.0%}).")
|
|
if not out:
|
|
out.append("Both eyes are tracked everywhere you've looked so far.")
|
|
return out
|
|
|
|
# --- Drawing (cairo) ---
|
|
|
|
def draw(self, cr, w, h, text, now):
|
|
# Right of the probe's collapsed title bar, under its toolbar (top right).
|
|
left = 300
|
|
top = 190
|
|
text(cr, left, top - 60, "Headset fit", (1, 1, 1), 30)
|
|
text(cr, left, top - 28, "Adjust the headset and watch each eye. Enter: guided check. R: start over.",
|
|
(0.8, 0.8, 0.8), 18)
|
|
card_w = min(560, (w - left - 80) / 2)
|
|
mh = max(0, min(card_w * 0.8, h - top - 280 - 200))
|
|
for k in (0, 1):
|
|
x = left + k * (card_w + 40)
|
|
self.draw_card(cr, x, top, card_w, text, now, k)
|
|
self.draw_map(cr, x, top + 280, card_w, mh, text, k)
|
|
y = top + 280 + (mh + 60 if mh >= 80 else 0)
|
|
for line in self.hints()[:4]:
|
|
for part in wrap(line, max(40, int((w - left - 40) / 10))):
|
|
if y > h - 30:
|
|
break
|
|
text(cr, left, y, part, (1, 0.95, 0.75), 18)
|
|
y += 26
|
|
y += 8
|
|
step = self.guide_step(now)
|
|
g = self.guide
|
|
if step:
|
|
kind, what, left_s = step
|
|
if kind == "dot":
|
|
fx, fy = what
|
|
x, y = fx * w, fy * h
|
|
cr.set_source_rgba(1, 0.85, 0.2, 0.95)
|
|
cr.arc(x, y, 14 + 4 * math.sin(now * 6), 0, 2 * math.pi)
|
|
cr.fill()
|
|
else:
|
|
text(cr, w / 2 - 260, h / 2, f"{what} ({left_s:.0f})", (1, 0.85, 0.2), 34)
|
|
elif g and g["step"] >= len(GUIDE):
|
|
self.draw_guide_results(cr, w, h, text)
|
|
|
|
def draw_card(self, cr, x, y, cw, text, now, k):
|
|
cr.set_source_rgba(1, 1, 1, 0.06)
|
|
cr.rectangle(x, y, cw, 230)
|
|
cr.fill()
|
|
word, col = self.status(k)
|
|
text(cr, x + 16, y + 38, EYES[k], (1, 1, 1), 26)
|
|
cr.select_font_face("sans")
|
|
cr.set_font_size(26)
|
|
text(cr, x + cw - 16 - cr.text_extents(word).x_advance, y + 38, word, col, 26)
|
|
rows = [("Open", self.open[k]), ("Signal", self.signal(k)), ("Seen, last 10 s", self.tracked_share(k, now))]
|
|
yy = y + 70
|
|
for label, v in rows:
|
|
text(cr, x + 16, yy + 16, label, (0.85, 0.85, 0.85), 17)
|
|
bx, bw = x + 170, cw - 250
|
|
cr.set_source_rgba(1, 1, 1, 0.12)
|
|
cr.rectangle(bx, yy, bw, 20)
|
|
cr.fill()
|
|
if v is not None:
|
|
v = min(1.0, max(0.0, v))
|
|
cr.set_source_rgba(*bar_colour(v), 0.9)
|
|
cr.rectangle(bx, yy, bw * v, 20)
|
|
cr.fill()
|
|
text(cr, bx + bw + 10, yy + 16, f"{v:.0%}", (0.9, 0.9, 0.9), 17)
|
|
yy += 36
|
|
d = self.durations[k]
|
|
longest = max(d) if d else 0
|
|
n = self.losses[k]
|
|
text(cr, x + 16, yy + 22, f"Lost {n} time{'' if n == 1 else 's'}" + (f", longest {longest:.1f} s" if longest >= 0.05 else ""),
|
|
(0.85, 0.85, 0.85), 17)
|
|
|
|
def draw_map(self, cr, x, y, mw, mh, text, k):
|
|
"""Where you looked (yaw across, pitch up), each cell coloured by how often this eye
|
|
was lost there: green never, red always, dark: not looked there yet."""
|
|
if mh < 80:
|
|
return
|
|
cols = int((YAW[1] - YAW[0]) / CELL)
|
|
rows = int((PITCH[1] - PITCH[0]) / CELL)
|
|
cw, ch = mw / cols, mh / rows
|
|
text(cr, x, y - 8, f"Where the {EYES[k].lower()} gets lost", (0.85, 0.85, 0.85), 17)
|
|
for i in range(cols):
|
|
yaw_i = math.floor(YAW[1] / CELL) - 1 - i # left of the map is your left (+yaw)
|
|
for j in range(rows):
|
|
pitch_j = math.floor(PITCH[1] / CELL) - 1 - j
|
|
c = self.cells[k].get((yaw_i, pitch_j))
|
|
cx, cy = x + i * cw, y + j * ch
|
|
if c and c[0] >= 10:
|
|
share = c[1] / c[0]
|
|
cr.set_source_rgba(*bar_colour(1 - share), 0.75)
|
|
else:
|
|
cr.set_source_rgba(1, 1, 1, 0.05)
|
|
cr.rectangle(cx + 1, cy + 1, cw - 2, ch - 2)
|
|
cr.fill()
|
|
# Straight ahead, and the gaze now.
|
|
def at(yaw, pitch):
|
|
return x + (YAW[1] - yaw) / (YAW[1] - YAW[0]) * mw, y + (PITCH[1] - pitch) / (PITCH[1] - PITCH[0]) * mh
|
|
cr.set_source_rgba(1, 1, 1, 0.35)
|
|
cr.set_line_width(1)
|
|
ox, oy = at(0, 0)
|
|
cr.move_to(ox - 10, oy)
|
|
cr.line_to(ox + 10, oy)
|
|
cr.move_to(ox, oy - 10)
|
|
cr.line_to(ox, oy + 10)
|
|
cr.stroke()
|
|
text(cr, x, y + mh + 20, "+ ahead, bottom rows: keyboard", (0.6, 0.6, 0.6), 14)
|
|
if self.gaze:
|
|
gx, gy = at(max(YAW[0], min(YAW[1], self.gaze[0])), max(PITCH[0], min(PITCH[1], self.gaze[1])))
|
|
cr.set_source_rgba(1, 1, 1, 0.95)
|
|
cr.arc(gx, gy, 5, 0, 2 * math.pi)
|
|
cr.fill()
|
|
|
|
def draw_guide_results(self, cr, w, h, text):
|
|
res = self.guide["results"]
|
|
lines = []
|
|
for (kind, what, _), (n, l0, l1) in zip(GUIDE, res):
|
|
if not n:
|
|
continue
|
|
name = what if kind == "look" else "dot at {:.0%}, {:.0%}".format(*what)
|
|
lines.append(f"{name}: left lost {l0 / n:.0%}, right {l1 / n:.0%}")
|
|
y = h / 2 - 20 * len(lines)
|
|
text(cr, w / 2 - 300, y - 40, "Guided check", (1, 0.85, 0.2), 26)
|
|
for line in lines:
|
|
text(cr, w / 2 - 300, y, line, (1, 1, 1), 19)
|
|
y += 30
|
|
|
|
|
|
def looking_down(yaw, pitch):
|
|
"""A look down at the keyboard: the "down" region, which ft-gazed doesn't send on."""
|
|
return pitch < -20
|
|
|
|
|
|
def bar_colour(v):
|
|
"""Red (0) through amber to green (1)."""
|
|
if v < 0.5:
|
|
return 1.0, 0.3 + 0.9 * v, 0.3
|
|
return 1.0 - 1.3 * (v - 0.5), 0.75 + 0.25 * (v - 0.5) * 2, 0.35
|
|
|
|
|
|
def wrap(s, width):
|
|
words, lines, cur = s.split(), [], ""
|
|
for wd in words:
|
|
if cur and len(cur) + 1 + len(wd) > width:
|
|
lines.append(cur)
|
|
cur = wd
|
|
else:
|
|
cur = f"{cur} {wd}".strip()
|
|
if cur:
|
|
lines.append(cur)
|
|
return lines
|