Files
DeeJanuz--frametop/gaze/tracker/lab/eyes_track.py
T
DeeJanuzandClaude Opus 5.5 ed75614f62 Bring our own eye tracker into the gaze folder, run by the gaze service
frame-eyes, a separate project until now, becomes gaze/tracker:
- ft-eyegrab (was fe-bufprobe) copies the eye-camera frames, read-only, out of
  SteamVR's eyetracking process. It runs as the system service
  frametop-eyegrab.service, which gaze/tracker/install.sh installs to
  /etc/frametop with sudo. It keeps only CAP_SYS_PTRACE, CAP_DAC_READ_SEARCH, and
  CAP_CHOWN, and copies frames only while /dev/shm/frametop-eyes-want is fresh,
  holding none of the tracker's buffers otherwise.
- ft-eyes (was fe-trackd) runs under ft-gazed in the dev container, with
  build/venv's pinned numpy and OpenCV: while Eye tracker is Own tracker, or on
  the probe's lease ("eyes SECONDS"). No sudo password or fe-live script at
  run time any more.
- lab/ holds the research tools (ft-eyes-score, -e2e, -record, -replay,
  -session) and findings.md. Recordings live outside the repo, in
  ~/.local/share/frametop/eyes/captures; .gitignore catches stray frame dumps.

Its socket is now @ft_eyes, its output /dev/shm/frametop-eyes-gaze, and its state
~/.local/state/frametop/gaze/eyes. On practice1 -> practice2 the whole live path
(ft-eyes-e2e) gives 1.30 deg median and 3.18 for the worst tenth, as before the
move (1.30, 3.21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:00:07 -06:00

42 lines
1.4 KiB
Python

"""Per-frame pupil ellipses through a recording, cached in the capture (numbers only).
ellipses(cap) -> {camera: array of rows (t, x, y, a, b, major, fill, glint mid x, y)}, every
EVERY-th frame of each camera; the glint midpoint is NaN when the pair isn't seen.
"""
import numpy as np
import eyes_lab # noqa: F401 (puts gaze/tracker on the path)
import eyes_pupil
EVERY = 2
VERSION = 2
def load_index(cap):
return np.array([[float(v) for v in l.split()]
for l in (cap / "index.txt").read_text().splitlines() if len(l.split()) == 4])
def ellipses(cap):
cache = cap / f"ellipses-v{VERSION}.npz"
if cache.exists():
z = np.load(cache)
return {0: z["cam0"], 1: z["cam1"]}
idx = load_index(cap)
frames = np.memmap(cap / "frames.raw", dtype=np.uint8, mode="r").reshape(-1, 400, 512)
idx = idx[:len(frames)]
out = {}
for c in (0, 1):
rows, prev = [], None
for i in np.where(idx[:, 2] == c)[0][::EVERY]:
p = eyes_pupil.find_pupil(frames[i], prev)
prev = p
if p is None:
continue
pair = eyes_pupil.glint_pair(p)
mx, my = ((pair[0][0] + pair[1][0]) / 2, (pair[0][1] + pair[1][1]) / 2) if pair else (np.nan, np.nan)
rows.append((idx[i, 3], p["x"], p["y"], p["a"], p["b"], p["major"], p["fill"], mx, my))
out[c] = np.array(rows).reshape(-1, 9)
np.savez(cache, cam0=out[0], cam1=out[1])
return out