mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 01:00:06 +02:00
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>
144 lines
5.4 KiB
Python
Executable File
144 lines
5.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ft-eyes-record: record a live session from the shared frames, so it can be replayed and
|
|
scored later (ft-eyes-score, ft-eyes-e2e), without root and alongside ft-eyes.
|
|
|
|
Reads /dev/shm/frametop-eyes-cams (ft-eyegrab, frametop-eyegrab.service) and writes every
|
|
frame of both cameras to a new recording NAME in eyes_lab.CAPTURES: frames.raw, index.txt
|
|
("<n> <slot> <camera> <time>", as ft-eyegrab --rec), and clocks.txt (wall clock and
|
|
CLOCK_MONOTONIC_RAW, read together). It touches /dev/shm/frametop-eyes-want every second, so
|
|
ft-eyegrab copies frames even without ft-eyes. About 2 GB a minute. Stops after SECONDS
|
|
(default 900), on Ctrl-C or SIGTERM, or when the disk gets below MIN_FREE_GB. The probe's
|
|
clicks are added later, on the Frame: `lab/py lab/ft-eyes-score --clicks NAME`.
|
|
|
|
Usage: lab/ft-eyes-record NAME [SECONDS] (host Python is enough: no numpy)
|
|
"""
|
|
import mmap
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import struct
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from eyes_lab import new_capture # noqa: E402
|
|
|
|
CAMS = os.environ.get("FT_EYES_CAMS", "/dev/shm/frametop-eyes-cams")
|
|
WANT = "/dev/shm/frametop-eyes-want"
|
|
W, H = 512, 400
|
|
MIN_FREE_GB = 20
|
|
|
|
|
|
class Share:
|
|
"""The shared frames. Layout: ft-eyegrab.c, share_head_t/share_entry_t."""
|
|
|
|
def __init__(self):
|
|
fd = os.open(CAMS, os.O_RDONLY)
|
|
try:
|
|
self.ino = os.fstat(fd).st_ino
|
|
self.mm = mmap.mmap(fd, 0, prot=mmap.PROT_READ)
|
|
finally:
|
|
os.close(fd)
|
|
magic, version, w, h, self.slots, self.esize = struct.unpack_from("<6I", self.mm, 0)
|
|
if magic != 0x31434546 or version != 1 or (w, h) != (W, H):
|
|
raise RuntimeError(f"{CAMS}: unexpected header")
|
|
|
|
def replaced(self):
|
|
try:
|
|
return os.stat(CAMS).st_ino != self.ino
|
|
except OSError:
|
|
return True
|
|
|
|
def count(self, cam):
|
|
return struct.unpack_from("<Q", self.mm, 24 + 8 * cam)[0]
|
|
|
|
def frame(self, cam, n):
|
|
"""(time, slot, bytes) of frame n of a camera, or None if it was overwritten."""
|
|
off = 64 + (cam * self.slots + n % self.slots) * self.esize
|
|
for _ in range(3):
|
|
seq = struct.unpack_from("<Q", self.mm, off)[0]
|
|
if seq & 1:
|
|
continue
|
|
data = self.mm[off + 64:off + 64 + W * H]
|
|
t, got, _cam, slot = struct.unpack_from("<dQII", self.mm, off + 8)
|
|
if struct.unpack_from("<Q", self.mm, off)[0] == seq and got == n:
|
|
return t, slot, data
|
|
return None
|
|
|
|
|
|
def touch_want():
|
|
"""Tell ft-eyegrab someone wants frames (it idles otherwise)."""
|
|
if "FT_EYES_CAMS" in os.environ:
|
|
return
|
|
try:
|
|
fd = os.open(WANT, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
|
os.utime(fd)
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def open_share(deadline):
|
|
while time.monotonic() < deadline:
|
|
touch_want()
|
|
try:
|
|
return Share()
|
|
except (OSError, ValueError, RuntimeError):
|
|
time.sleep(0.5)
|
|
sys.exit(f"ft-eyes-record: no {CAMS} (is frametop-eyegrab.service running? gaze/tracker/install.sh)")
|
|
|
|
|
|
def main(argv):
|
|
if not argv or argv[0].startswith("-"):
|
|
sys.exit(__doc__)
|
|
secs = float(argv[1]) if len(argv) > 1 else 900.0
|
|
out = new_capture(argv[0])
|
|
stop = []
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
signal.signal(sig, lambda *_: stop.append(1))
|
|
share = open_share(time.monotonic() + 10)
|
|
(out / "clocks.txt").write_text(f"{time.time()} {time.clock_gettime(time.CLOCK_MONOTONIC_RAW)}\n")
|
|
seen = [share.count(0), share.count(1)]
|
|
written = dropped = 0
|
|
end = time.monotonic() + secs
|
|
check = touched = time.monotonic()
|
|
with open(out / "frames.raw", "wb") as frames, open(out / "index.txt", "w") as index:
|
|
while not stop and time.monotonic() < end:
|
|
new = []
|
|
for c in (0, 1):
|
|
n = share.count(c)
|
|
if n - seen[c] > share.slots: # fell behind: those frames are gone
|
|
dropped += n - seen[c] - share.slots
|
|
seen[c] = n - share.slots
|
|
for i in range(seen[c], n):
|
|
f = share.frame(c, i)
|
|
if f is None:
|
|
dropped += 1
|
|
else:
|
|
new.append((f[0], f[1], c, f[2]))
|
|
seen[c] = n
|
|
for t, slot, c, data in sorted(new, key=lambda f: f[0]):
|
|
frames.write(data)
|
|
index.write(f"{written} {slot} {c} {t:.6f}\n")
|
|
written += 1
|
|
if not new:
|
|
time.sleep(0.002)
|
|
if time.monotonic() - touched > 1:
|
|
touched = time.monotonic()
|
|
touch_want()
|
|
if time.monotonic() - check > 5:
|
|
check = time.monotonic()
|
|
if shutil.disk_usage(out).free < MIN_FREE_GB * 1e9:
|
|
print(f"ft-eyes-record: under {MIN_FREE_GB} GB free, stopping", file=sys.stderr)
|
|
break
|
|
if share.replaced():
|
|
print("ft-eyes-record: the frame share was restarted; following it", file=sys.stderr)
|
|
share = open_share(time.monotonic() + 10)
|
|
seen = [share.count(0), share.count(1)]
|
|
print(f"ft-eyes-record: {written} frames ({dropped} dropped) in {out}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|