Compare commits

...
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 e44837cee6 Gaze: the hidden panel waits for a command instead of waking every 50 ms
The calibration panel runs for as long as the gaze service does, hidden nearly all the time,
and it woke 20 to 30 times a second to look at its socket and SteamVR's events: about 0.9% of
a core, the main cost left with gaze idle.

It now waits in poll() on its command socket: up to a second while hidden, and up to 10 ms
while shown, as before (it still drains SteamVR's events each pass, so a quit is acknowledged
within a second while hidden). A command wakes it at once, so "show" draws sooner than
before. With --watch-stdin, its stdin closing wakes it as well, so stopping it doesn't wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:28:02 -06:00
DeeJanuzandClaude Opus 5.5 95c5d056cf Gaze: ft-gaze's loop runs every 4 ms instead of 2
ft-gaze's loop slept 2 ms, so 500 times a second it read the head pose
(GetDeviceToAbsoluteTrackingPose), checked the eye tracker's counter, and drained SteamVR's
events, for samples that come 90 times a second.

It now sleeps 4 ms. A new sample is printed within 4 ms of appearing, 2 on average (was 1),
and the pose history still has a pose within 2 ms of any sample's time, which keeps the
head-pose error under 0.2 degrees for a head turning 100 degrees a second. Sleeping until
the next sample is due would have thinned the pose history to 11 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:26:54 -06:00
DeeJanuzandClaude Opus 5.5 0c95d1d731 Gaze: ft-gaze prints and reads only the sources in use
ft-gaze computed and printed all six sources for every sample: about 1.3 KB of JSON a line
with our tracker (practice2), 120 KB a second through podman's stdio relay for ft-gazed to
json.loads 90 times a second. It also read SteamVR's gaze action for every sample outside
games (UpdateActionState and GetEyeTrackingDataRelativeToNow, two calls into vrserver, 180 a
second), though with our tracker ft-gazed only uses own and mmap1.

- ft-gaze takes --sources LIST (action, mmap1, mmap2, left, right, own, and eye for the EYE
  object; all by default, so the probe and ft-eyes-session are unchanged), and with
  --watch-stdin a line "sources LIST" on stdin switches them. A source left out isn't read
  and prints as {"ok":0} ("eye" as null), so every line keeps the same keys. An older
  ft-gaze ignores both, and prints everything as before.
- ft-gazed asks for what it reads: own,mmap1 with our tracker; left,right,mmap1 with
  SteamVR's eyes; the source plus mmap1 and mmap2 on the older one-source path. While a
  check or the calibration runs or waits to open, all of them, since checks record every
  source (the calibration fits the action's correction too) and the fit check reads "eye".
  It switches as soon as that changes, well inside the check's 0.45 s settle.
- So the action is read only during checks, or with --source action.

On recorded samples, a line with own and mmap1 is about 700 bytes instead of 1,300
(practice2), and one with left, right and mmap1 about 550 instead of 940 (test1).
gaze/test/idle-test.py now checks that ft-gaze starts with every source for a check and is
then switched to those in use, without the action or own; all its checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:23:53 -06:00
DeeJanuzandClaude Opus 5.5 060b4957f8 Eye tracker: ft-eyegrab checks only the slot each camera writes next
While copying, ft-eyegrab woke every 300 us (about 1,500 to 3,000 times a second) and
fingerprinted all eight slots each time: 8 x 256 strided reads from DMA-BUF memory.

- Each look now checks only the slot each camera writes next. The order is known (camera 0
  3,0,1,2; camera 1 7,5,4,6,5,7,6,4), and the next slot follows from the last two; the table
  starts from those orders and learns from every frame, so a SteamVR update that changes
  them costs a few seconds of full scans, not frames.
- A camera with nothing in its expected slot 1.5 frames after its last one, or with no
  order yet, gets all four slots checked, as before. A frame that turns up in an unexpected
  slot means full scans for that camera for 2 s.
- A slot's fingerprint is taken again when it stops being one of the two in use, so a later
  check sees only a new frame. A frame is still passed on when its camera starts the frame
  after next.
- Between frames it sleeps until 2.5 ms before the next is due, then looks every 1 ms, with
  0.5 ms of timer slack (PR_SET_TIMERSLACK, --share only). With no frames from either
  camera for 0.5 s (headset off) it looks every 4 ms.
- --rec keeps its 0.3 ms polls (and the expected-slot checks), for its timestamps.

Tested offline by building poll_frames against simulated cameras that write each frame in
four bursts, the last after the next frame starts (6 s, both cameras): 1,076 frames passed
on, none torn or skipped, with the known orders and with camera 1 in a different order.
Wakeups 1,486/s -> 207/s, the poller's CPU 3.6% -> 0.8% of a core (in plain memory; the real
DMA-BUF reads cost more), and a frame's start is seen 1.35 ms after it begins on average
instead of 0.76. With a camera stalling 15 ms every 2 s, the old poller passed on 22 torn
frames and the new one 8 or fewer.

Built (glibc 2.38 symbols at most, the host has 2.39), not installed: it runs as root from
/etc/frametop, so it takes effect only after gaze/tracker/install.sh (sudo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:20:00 -06:00
DeeJanuzandClaude Opus 5.5 7851ce90cc Eye tracker: ft-eyes sleeps until the next frame is due
ft-eyes looked for new frames about 1,000 times a second: each pass of its loop asked the
control socket with a non-blocking recvfrom (a BlockingIOError nearly every time), read
both cameras' counters, and slept 1 ms. The frames come every 11.1 ms per camera, and only
as counters in ft-eyegrab's shared memory, so there's no fd to wait on.

Now each pass ends in select() on the control socket, with a timeout until 2 ms before the
next frame of either camera is due (from when its last one was seen), then every 1 ms until
it comes. A command wakes it at once. A camera with no frame for 0.1 s (headset off,
grabber idle) isn't waited for, and with both stopped it looks every 20 ms. Waiting for the
frame grabber's file uses the same select, 0.2 s at a time, instead of sleeping through
commands.

A new frame is still seen within about 1 ms of when it lands. On a synthetic share at 90 Hz
per camera, on a heavily loaded headset (load average 23, so ft-eyes rarely sat idle), its
waits went from 178 to 81 a second; unloaded, the old loop's 1 ms sleeps add up to about
1,000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:15:27 -06:00
DeeJanuzandClaude Opus 5.5 a11cef49ba Gaze: ft-eyes and ft-gaze below SteamVR's priority
ft-eyes and ft-gaze run in the dev container through distrobox, so they live in podman's
libpod scope: frametop-gaze.service's limits never reach them, and they ran at nice 0
next to vrcompositor and vrserver, also at nice 0.

- ft-eyes sets itself to nice 10 and SCHED_BATCH at start, before its threads. Batch
  turns off wakeup preemption, so a frame ft-eyes wakes up for can wait out a running
  compositor's turn; a few ms late costs the gaze little.
- ft-gaze sets nice 5 before its threads start, but stays SCHED_OTHER: each sample goes
  on to the pointer, and batch would add the same wait to every one of them.
- Both only ever lower their priority (a higher nice already set wins), and a failure
  is logged and ignored. Checked in the dev container: nice 0 -> 10, policy 0 -> 3
  (SCHED_BATCH) without any capability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:11:15 -06:00
DeeJanuzandClaude Opus 5.5 8a02e41b21 Eye tracker: one thread for OpenCV and numpy
Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for
pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000
times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's
OpenBLAS also started 8 threads that never had work.

eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and
OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins).

Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core
with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s,
system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from
21-36 to 57-69 a second per eye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:08:16 -06:00
9 changed files with 268 additions and 32 deletions

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it. The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it.
- `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. - `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. The gaze service asks it for only the sources it uses (`--sources`, and `sources LIST` on its stdin): our tracker and mmap set 1 with Own tracker, about 700 bytes a line instead of 1.3 KB, and every source while a check or the calibration runs. So SteamVR's gaze action, the only source that calls into vrserver (twice a sample), is read only then. The probe gets them all.
- `gazecal.py` has what the probe and the gaze service share: the correction models, filters, and the reader for SteamVR's eye tracking log. - `gazecal.py` has what the probe and the gaze service share: the correction models, filters, and the reader for SteamVR's eye tracking log.
- `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below. - `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below.
- `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments. - `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments.
@@ -63,7 +63,7 @@ Ground rules, for anyone changing it:
- **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only). - **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only).
- **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`. - **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`.
- **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs. - **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs.
- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. Replays, scoring, and training go to a PC. - **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. It also runs at nice 10 with SCHED_BATCH, and ft-gaze at nice 5 (not batch, since each sample goes on to the pointer): both run in the dev container's podman scope, out of reach of the gaze service's unit, on the cores vrcompositor and vrserver use at nice 0. Replays, scoring, and training go to a PC.
## Headset fit ## Headset fit
+77 -8
View File
@@ -4,7 +4,14 @@
// Every eye tracker sample (90 Hz) becomes one JSON line on stdout with each gaze source // Every eye tracker sample (90 Hz) becomes one JSON line on stdout with each gaze source
// hit-tested against the Frametop screens: // hit-tested against the Frametop screens:
// //
// Options: -v (log action errors), --watch-stdin (quit when stdin closes). // Options: -v (log action errors), --watch-stdin (quit when stdin closes; until then, a line
// "sources LIST" on stdin switches the sources as --sources does), --sources LIST (comma-
// separated: action, mmap1, mmap2, left, right, own, and eye for EYE; or all, the default).
// Sources left out are read not at all and print as {"ok":0} ("eye" as null), so every line
// keeps the same keys. The gaze service asks for the ones it uses (own and mmap1 with our
// tracker), and all of them while a check or the calibration runs. Only the action costs
// SteamVR anything (two calls into vrserver per sample), and a line with every source is
// about 1.5 KB, 130 KB a second through podman's relay.
// //
// {"t":<sample time, CLOCK_MONOTONIC_RAW s>,"age":<ms old when read>,"n":<sample counter>, // {"t":<sample time, CLOCK_MONOTONIC_RAW s>,"age":<ms old when read>,"n":<sample counter>,
// "head":{"yaw":..,"pitch":..,"hit":HIT}, head forward ray (for head nudging) // "head":{"yaw":..,"pitch":..,"hit":HIT}, head forward ray (for head nudging)
@@ -58,6 +65,7 @@
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cerrno>
#include <chrono> #include <chrono>
#include <climits> #include <climits>
#include <cmath> #include <cmath>
@@ -72,6 +80,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/resource.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/un.h> #include <sys/un.h>
@@ -389,6 +398,30 @@ std::string SrcJson(const std::vector<Screen> &screens, const vr::HmdMatrix34_t
return buf + extra + "\"hit\":" + HitJson(screens, head, yaw, pitch) + "}"; return buf + extra + "\"hit\":" + HitJson(screens, head, yaw, pitch) + "}";
} }
// Sources (--sources, "sources LIST" on stdin): a bit each.
enum : unsigned { kAction = 1, kMmap1 = 2, kMmap2 = 4, kLeft = 8, kRight = 16, kOwn = 32, kEye = 64, kAll = 127 };
bool ParseSources(const std::string &list, unsigned &mask) {
static const struct {
const char *name;
unsigned bit;
} names[] = {{"action", kAction}, {"mmap1", kMmap1}, {"mmap2", kMmap2}, {"left", kLeft},
{"right", kRight}, {"own", kOwn}, {"eye", kEye}, {"all", kAll}};
unsigned m = 0;
size_t at = 0;
while (at <= list.size()) {
const size_t comma = std::min(list.find(',', at), list.size());
const std::string name = list.substr(at, comma - at);
bool known = false;
for (const auto &n : names)
if (name == n.name) m |= n.bit, known = true;
if (!known) return false;
at = comma + 1;
}
mask = m;
return true;
}
// Head poses of the last half second, so a sample can use the pose at its own time. // Head poses of the last half second, so a sample can use the pose at its own time.
class PoseHistory { class PoseHistory {
public: public:
@@ -426,17 +459,42 @@ std::string ExeDir() {
int main(int argc, char **argv) { int main(int argc, char **argv) {
bool verbose = false, watchStdin = false; bool verbose = false, watchStdin = false;
std::atomic<unsigned> sources{kAll};
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "-v") == 0) verbose = true; if (std::strcmp(argv[i], "-v") == 0) verbose = true;
if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true; if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true;
if (std::strcmp(argv[i], "--sources") == 0 && i + 1 < argc) {
unsigned m;
if (ParseSources(argv[++i], m))
sources = m;
else
std::fprintf(stderr, "ft-gaze: --sources %s: unknown source (all used)\n", argv[i]);
} }
}
// Nice 5, before any thread starts (they inherit it): we run in the dev container's podman
// scope, beside vrcompositor and vrserver at nice 0, and the gaze service's unit doesn't
// reach us. Not SCHED_BATCH, as ft-eyes is: that would let each wakeup wait out another
// task's turn, and each sample goes on to the pointer.
errno = 0;
const int nice0 = getpriority(PRIO_PROCESS, 0);
if (errno == 0 && nice0 < 5 && setpriority(PRIO_PROCESS, 0, 5) != 0)
std::fprintf(stderr, "ft-gaze: nice: %s\n", std::strerror(errno));
// --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which // --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which
// passes neither its signals nor a closed stdout on to us, but does pass stdin's end. // passes neither its signals nor a closed stdout on to us, but does pass stdin's end.
// Lines on stdin until then: "sources LIST".
std::atomic<bool> stdinClosed{false}; std::atomic<bool> stdinClosed{false};
if (watchStdin) if (watchStdin)
std::thread([&stdinClosed] { std::thread([&stdinClosed, &sources] {
char c[256]; char c[256];
while (read(0, c, sizeof c) > 0) { std::string line;
ssize_t n;
while ((n = read(0, c, sizeof c)) > 0) {
line.append(c, size_t(n));
for (size_t nl; (nl = line.find('\n')) != std::string::npos; line.erase(0, nl + 1)) {
unsigned m;
if (line.compare(0, 8, "sources ") == 0 && ParseSources(line.substr(8, nl - 8), m)) sources = m;
}
if (line.size() > 4096) line.clear(); // no newline in sight: not ours
} }
stdinClosed = true; stdinClosed = true;
}).detach(); }).detach();
@@ -509,6 +567,7 @@ int main(int argc, char **argv) {
if (fresh && hp.bPoseIsValid) { if (fresh && hp.bPoseIsValid) {
lastEmit = now; lastEmit = now;
const unsigned want = sources;
const auto list = screens.Get(); const auto list = screens.Get();
const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking; const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking;
vr::HmdMatrix34_t headThen = headNow; vr::HmdMatrix34_t headThen = headNow;
@@ -517,7 +576,7 @@ int main(int argc, char **argv) {
// SteamVR's action: a room-space origin and fixation point, turned into the head // SteamVR's action: a room-space origin and fixation point, turned into the head
// frame so every source reports the same kind of angles. // frame so every source reports the same kind of angles.
std::string action = "{\"ok\":0}"; std::string action = "{\"ok\":0}";
if (!inGame) { if (!inGame && (want & kAction)) {
vr::VRActiveActionSet_t active{}; vr::VRActiveActionSet_t active{};
active.ulActionSet = set; active.ulActionSet = set;
active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin; active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
@@ -560,26 +619,33 @@ int main(int argc, char **argv) {
return std::string(b); return std::string(b);
}; };
char extra[256]; char extra[256];
if (want & kMmap1) {
std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1), std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1),
s.open[0], s.open[1], lr(s.left1, s.right1)); s.open[0], s.open[1], lr(s.left1, s.right1));
m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1)); m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1));
}
if (want & kMmap2) {
std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2)); std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2));
m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2)); m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2));
left = SrcJson(list, headThen, s.left2); }
right = SrcJson(list, headThen, s.right2); if (want & kLeft) left = SrcJson(list, headThen, s.left2);
if (want & kRight) right = SrcJson(list, headThen, s.right2);
// "new" compares with the last sample, so the last measurement is kept either way.
const float *m = s.meas; const float *m = s.meas;
const bool newL = m[0] != lastMeas[0] || m[1] != lastMeas[1]; const bool newL = m[0] != lastMeas[0] || m[1] != lastMeas[1];
const bool newR = m[2] != lastMeas[2] || m[3] != lastMeas[3]; const bool newR = m[2] != lastMeas[2] || m[3] != lastMeas[3];
std::memcpy(lastMeas, m, sizeof lastMeas); std::memcpy(lastMeas, m, sizeof lastMeas);
if (want & kEye) {
std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}", std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}",
(m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR)); (m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR));
eye = extra; eye = extra;
} }
}
// Our tracker: its own sample time picks the head pose, like the mmap's. // Our tracker: its own sample time picks the head pose, like the mmap's.
std::string own = "{\"ok\":0}"; std::string own = "{\"ok\":0}";
OwnSample o; OwnSample o;
if (ownFile.Read(o) && now - o.t < 0.1) { if ((want & kOwn) && ownFile.Read(o) && now - o.t < 0.1) {
vr::HmdMatrix34_t headOwn = headNow; vr::HmdMatrix34_t headOwn = headNow;
history.At(o.t, headOwn); history.At(o.t, headOwn);
auto pair = [](bool ok, float a, float b) { auto pair = [](bool ok, float a, float b) {
@@ -624,7 +690,10 @@ int main(int argc, char **argv) {
break; break;
} }
if (stdinClosed) break; if (stdinClosed) break;
std::this_thread::sleep_for(std::chrono::milliseconds(2)); // 250 passes a second: a new sample is printed within 4 ms (2 on average) of appearing,
// and the pose history has a pose within 2 ms of any sample's time (a 0.2 degree head
// turn at 100 degrees a second). Every 2 ms read the pose and the events twice as often.
std::this_thread::sleep_for(std::chrono::milliseconds(4));
} }
screens.Stop(); screens.Stop();
vr::VR_Shutdown(); vr::VR_Shutdown();
+38 -3
View File
@@ -76,6 +76,12 @@ off. A check asked for while idle waits for the tracker to start (at most WAKE_S
tracker's next click after it starts again re-seats it, as after the headset was off, so turning tracker's next click after it starts again re-seats it, as after the headset was off, so turning
gaze mode on after a while opens the quick check. gaze mode on after a while opens the quick check.
ft-gaze prints only the sources this uses ("sources LIST" on its stdin, see wanted_sources):
own and mmap1 with our tracker, left, right and mmap1 with SteamVR's eyes, and every source
while a check or the calibration runs or is asked for, since those record them all. So
SteamVR's gaze action, the one source that calls into vrserver, is read only then (or with
--source action).
Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or
our tracker asks for a click, and the full calibration when gaze mode comes on without one, our tracker asks for a click, and the full calibration when gaze mode comes on without one,
both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too). both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too).
@@ -260,6 +266,7 @@ class Service:
self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own") self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own")
self.checks = Checks(self, self.sel) self.checks = Checks(self, self.sel)
self.proc = None self.proc = None
self.proc_sources = None # what ft-gaze was last told to print
self.buf = b"" self.buf = b""
self.restart_at = 0.0 self.restart_at = 0.0
self.running = True self.running = True
@@ -459,6 +466,31 @@ class Service:
# --- ft-gaze --- # --- ft-gaze ---
def wanted_sources(self):
"""The sources ft-gaze should print (its --sources): what on_sample and the checks read.
mmap1 always (blinks, lost eyes, and the headset going on, from its openness and
variances); everything while a check runs or waits, since they record every source."""
c = self.checks
if c.active or c.pending:
return "all"
kind = self.kind
if kind == "own":
return "own,mmap1"
if kind == "eyes":
return "left,right,mmap1"
return ",".join(dict.fromkeys((self.source, "mmap1", "mmap2"))) # mmap2: each eye, for the fallback
def sync_sources(self):
want = self.wanted_sources()
if not self.proc or want == self.proc_sources:
return
try:
self.proc.stdin.write(f"sources {want}\n".encode())
self.proc.stdin.flush()
except (OSError, ValueError):
return # it's stopping: read_stdout notices
self.proc_sources = want
def start_helper(self): def start_helper(self):
if not HELPER.exists(): if not HELPER.exists():
log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh") log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh")
@@ -469,9 +501,11 @@ class Service:
subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False) subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False)
distrobox = Path.home() / ".local" / "bin" / "distrobox" distrobox = Path.home() / ".local" / "bin" / "distrobox"
# ft-gaze quits when its stdin closes: the one thing distrobox passes on. # ft-gaze quits when its stdin closes: the one thing distrobox passes on.
self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin"], env=env, sources = self.wanted_sources()
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin",
start_new_session=True) "--sources", sources], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, start_new_session=True)
self.proc_sources = sources
os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stdout.fileno(), False)
os.set_blocking(self.proc.stderr.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False)
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
@@ -876,6 +910,7 @@ class Service:
elif key.data == "stderr" and self.proc: elif key.data == "stderr" and self.proc:
self.read_stderr() self.read_stderr()
self.checks.tick() self.checks.tick()
self.sync_sources()
if now >= next_periodic: if now >= next_periodic:
self.periodic() self.periodic()
next_periodic = now + 1.0 next_periodic = now + 1.0
+6 -1
View File
@@ -47,6 +47,7 @@
#include <drm_fourcc.h> #include <drm_fourcc.h>
#include <fcntl.h> #include <fcntl.h>
#include <gbm.h> #include <gbm.h>
#include <poll.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/un.h> #include <sys/un.h>
#include <unistd.h> #include <unistd.h>
@@ -524,7 +525,11 @@ int main(int argc, char **argv) {
if (!shown) ov->ShowOverlay(h), shown = true; if (!shown) ov->ShowOverlay(h), shown = true;
dirty = false; dirty = false;
} }
std::this_thread::sleep_for(std::chrono::milliseconds(visible ? 10 : 50)); // Until a command comes, or 10 ms while shown (SteamVR's events). Hidden, it waits up to a
// second: it woke 20 to 30 times a second for nothing, the main cost left with gaze idle.
// A closed stdin (--watch-stdin) wakes it too, so quitting doesn't wait.
pollfd fds[2] = {{sock, POLLIN, 0}, {0, POLLIN, 0}};
poll(fds, watchStdin ? 2 : 1, visible ? 10 : 1000);
} }
ov->DestroyOverlay(h); ov->DestroyOverlay(h);
buffers.Drop(); buffers.Drop();
+25 -5
View File
@@ -37,14 +37,26 @@ gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0)
logs = [] logs = []
gazed.log = gazecheck.log = lambda msg: logs.append(msg) gazed.log = gazecheck.log = lambda msg: logs.append(msg)
# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. # The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. It logs the
# sources it was asked for: "argv LIST" (--sources), then "line LIST" for each "sources LIST".
tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-") tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-")
FAKE = os.path.join(tmp, "ft-gaze") FAKE = os.path.join(tmp, "ft-gaze")
SOURCES_LOG = os.path.join(tmp, "sources.log")
with open(FAKE, "w") as f: with open(FAKE, "w") as f:
f.write('''import json, select, sys, time f.write('''import json, os, select, sys, time
log = open(sys.argv[1], "a", buffering=1)
log.write("argv " + (sys.argv[sys.argv.index("--sources") + 1] if "--sources" in sys.argv else "-") + "\\n")
buf = b""
while True: while True:
if select.select([sys.stdin], [], [], 1 / 90)[0] and not sys.stdin.read(1): if select.select([sys.stdin], [], [], 1 / 90)[0]:
data = os.read(0, 4096)
if not data:
break break
buf += data
while b"\\n" in buf:
line, buf = buf.split(b"\\n", 1)
if line.startswith(b"sources "):
log.write("line " + line[8:].decode() + "\\n")
eye = {"hy": 1.0, "hp": 2.0} eye = {"hy": 1.0, "hp": 2.0}
print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001], print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001],
"open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True) "open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True)
@@ -55,8 +67,9 @@ started = []
def start_helper(self): def start_helper(self):
"""ft-gaze, straight from here instead of the dev container.""" """ft-gaze, straight from here instead of the dev container."""
import selectors import selectors
self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE, self.proc = subprocess.Popen([sys.executable, FAKE, SOURCES_LOG, "--sources", self.wanted_sources()],
stderr=subprocess.PIPE) stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.proc_sources = self.wanted_sources()
os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stdout.fileno(), False)
os.set_blocking(self.proc.stderr.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False)
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
@@ -142,17 +155,24 @@ check("lease over (2 s + IDLE_AFTER): idle", wait(lambda: not svc.awake, 4.5), T
time.sleep(0.5) time.sleep(0.5)
logs.clear() logs.clear()
open(SOURCES_LOG, "w").close()
count = len(started) count = len(started)
check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first") check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first")
check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True) check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True)
check("once the tracker sends, it ran (and says why it couldn't open)", check("once the tracker sends, it ran (and says why it couldn't open)",
wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True) wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True)
check("nothing left pending", svc.checks.pending, None) check("nothing left pending", svc.checks.pending, None)
sources = open(SOURCES_LOG).read().split("\n")
check("ft-gaze started with every source for the check", sources[0], "argv all")
in_use = svc.wanted_sources()
check("then only those in use (SteamVR's tracker: not the action, not own)",
(f"line {in_use}" in sources, in_use != "all", "action" in in_use, "own" in in_use), (True, True, False, False))
check("idle again after", wait(lambda: not svc.awake, 3), True) check("idle again after", wait(lambda: not svc.awake, 3), True)
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True) print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
svc.running = False svc.running = False
time.sleep(0.7) time.sleep(0.7)
os.remove(FAKE) os.remove(FAKE)
os.remove(SOURCES_LOG)
os.rmdir(tmp) os.rmdir(tmp)
os._exit(1 if failures else 0) os._exit(1 if failures else 0)
+5
View File
@@ -9,6 +9,11 @@ the search runs again with a smaller closing.
import cv2 import cv2
import numpy as np import numpy as np
# One thread: OpenCV's pool of one per core costs more than it saves on a frame this small
# (a 140-240 px window while it follows the pupil). Its idle workers spun and yielded about
# 14,000 times a second each, a quarter of a core, beside SteamVR's compositor.
cv2.setNumThreads(1)
DARK = 30 # pupil pixels are below this (the face around it is 40-180) DARK = 30 # pupil pixels are below this (the face around it is 40-180)
MIN_AREA = 150 # pupil area range in pixels MIN_AREA = 150 # pupil area range in pixels
MAX_AREA = 20000 MAX_AREA = 20000
+7
View File
@@ -63,6 +63,13 @@ reading only.
frame when its camera starts the frame after next (no slot is rewritten sooner than three frame when its camera starts the frame after next (no slot is rewritten sooner than three
frames), ignores late writes to the frame just finished, and saves from a separate frames), ignores late writes to the frame just finished, and saves from a separate
thread. fit1 may hold a few percent of torn frames. thread. fit1 may hold a few percent of torn frames.
- `--share` (2026-10-03) checks only the slot each camera writes next, from the two before
(the orders above, learned again if they change; all four slots for 2 s after a frame turns
up elsewhere), sleeps until 2.5 ms before the next frame is due, then looks every 1 ms with
0.5 ms of timer slack. Against the 0.3 ms poll of all eight slots, on simulated cameras:
207 wakeups a second instead of 1,486, 0.8% of a core instead of 3.6% (more on the real
DMA-BUF memory), no torn or skipped frames, and a frame's start seen 1.35 ms late on
average instead of 0.76. `--rec` still polls all slots every 0.3 ms, for its times.
- Eye tracking stops when the headset is off ("HMD off, stopping eye tracking"), so - Eye tracking stops when the headset is off ("HMD off, stopping eye tracking"), so
recordings are empty then. recordings are empty then.
- **Which camera is which eye** (capture fit1, 2026-09-29, closing one eye at a time): - **Which camera is which eye** (capture fit1, 2026-09-29, closing one eye at a time):
+66 -9
View File
@@ -46,6 +46,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <time.h> #include <time.h>
@@ -288,20 +289,53 @@ static int eye_buffer(void) {
// Calls done(frame, slot, time) for every complete eye-camera frame until `seconds` pass // Calls done(frame, slot, time) for every complete eye-camera frame until `seconds` pass
// (forever if negative), a stop signal comes, the tracker process goes away, or keep() // (forever if negative), a stop signal comes, the tracker process goes away, or keep()
// (checked about every 0.25 s, when given) says to stop. // (checked about every 0.25 s, when given) says to stop. Looks every `poll_us`.
// //
// A frame lands over several milliseconds, in bursts, and its last bursts can come after // A frame lands over several milliseconds, in bursts, and its last bursts can come after
// the camera has started its next frame. A slot isn't rewritten until at least three frames // the camera has started its next frame. A slot isn't rewritten until at least three frames
// later (camera 0 cycles 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), so a frame is passed on when // later (camera 0 cycles 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), so a frame is passed on when
// its camera starts the frame after next. Changes to the slot just finished are late bursts, // its camera starts the frame after next. Changes to the slot just finished are late bursts,
// not a new frame. A frame's time is when its slot first changed. // not a new frame. A frame's time is when its slot first changed.
//
// Each look checks only the slot each camera writes next: the one that followed the last two
// before (after[][], seeded with the orders above and learned as frames come). Every check
// reads 256 words spread over a frame in DMA-BUF memory, so all eight slots each time was
// most of the cost. A camera with nothing in its expected slot for 1.5 frames, or with no
// order known yet, has all its slots checked until a frame comes. When that finds a frame
// somewhere else (the order changed, or a frame was missed), all its slots are checked for
// SCAN_AFTER_MISS, as before, while after[][] learns the new order. A slot's fingerprint is
// taken again when it stops being one of the two in use, so a check later sees only a new frame.
// Between frames it sleeps until FRAME_EARLY before the next one is due (the cameras run at
// 90 fps, within a ms of each other), then looks every `poll_us`.
#define FRAME_DUE (1.5 / 90) // s: a camera that hasn't started a frame by then gets all its slots checked
#define SCAN_AFTER_MISS 2.0 // s of checking all slots after a frame came in an unexpected one
#define CAMS_IDLE 0.5 // s without a frame from either camera: look 4 times less often
#define FRAME_EARLY 0.0025 // s before a frame is due to start looking for it
// When to start looking for the next frame: FRAME_EARLY before the first camera's is due. A
// camera already late (it lost its order, or stopped) means now.
static double next_due(const double last[2], double t) {
double due = 1e300;
for (int cam = 0; cam < 2; cam++) {
double d = last[cam] + 1.0 / 90 - FRAME_EARLY;
if (t - last[cam] > CAMS_IDLE) continue; // stopped: the other one sets the pace
if (d < due) due = d;
}
return due < 1e300 ? due : t;
}
static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8_t *, int, double), static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8_t *, int, double),
int (*keep)(void)) { int (*keep)(void), unsigned poll_us) {
static const int order[2][8] = {{3, 0, 1, 2, 3, 0, 1, 2}, {7, 5, 4, 6, 5, 7, 6, 4}};
int after[EYE_SLOTS][EYE_SLOTS];
memset(after, -1, sizeof after);
for (int c = 0; c < 2; c++)
for (int i = 0; i < 8; i++) after[order[c][i]][order[c][(i + 1) % 8]] = order[c][(i + 2) % 8];
uint64_t sig[EYE_SLOTS]; uint64_t sig[EYE_SLOTS];
double first[EYE_SLOTS]; double first[EYE_SLOTS];
int cur[2] = {-1, -1}, prev[2] = {-1, -1}; int cur[2] = {-1, -1}, prev[2] = {-1, -1};
for (int k = 0; k < EYE_SLOTS; k++) sig[k] = frame_sig(bufs[b].p + slot_start(k)), first[k] = 0; for (int k = 0; k < EYE_SLOTS; k++) sig[k] = frame_sig(bufs[b].p + slot_start(k)), first[k] = 0;
double start = now(), checked = start, kept = start; double start = now(), checked = start, kept = start, last[2] = {start, start}, scan_until[2] = {0, 0};
char proc[64]; char proc[64];
snprintf(proc, sizeof proc, "/proc/%d", pid); snprintf(proc, sizeof proc, "/proc/%d", pid);
while ((seconds < 0 || now() - start < seconds) && !stop_rec) { while ((seconds < 0 || now() - start < seconds) && !stop_rec) {
@@ -315,18 +349,36 @@ static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8
if (!keep()) return; if (!keep()) return;
kept = t; kept = t;
} }
for (int k = 0; k < EYE_SLOTS; k++) { for (int cam = 0; cam < 2; cam++) {
int next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1;
int all = next < 0 || t - last[cam] > FRAME_DUE || t < scan_until[cam];
int from = all ? cam * 4 : next, to = all ? cam * 4 + 4 : next + 1;
for (int k = from; k < to; k++) {
uint64_t s = frame_sig(bufs[b].p + slot_start(k)); uint64_t s = frame_sig(bufs[b].p + slot_start(k));
if (s == sig[k]) continue; if (s == sig[k]) continue;
sig[k] = s; sig[k] = s;
int cam = k >= 4;
if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst
if (prev[cam] >= 0) done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]); if (next >= 0 && k != next) scan_until[cam] = t + SCAN_AFTER_MISS;
if (prev[cam] >= 0) {
done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]);
after[prev[cam]][cur[cam]] = k;
// Out of use from now on: later changes are a new frame.
sig[prev[cam]] = frame_sig(bufs[b].p + slot_start(prev[cam]));
}
prev[cam] = cur[cam]; prev[cam] = cur[cam];
cur[cam] = k; cur[cam] = k;
first[k] = t; first[k] = t;
last[cam] = t;
next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1;
} }
usleep(300); }
double wait = poll_us * 1e-6;
if (t - last[0] > CAMS_IDLE && t - last[1] > CAMS_IDLE) {
wait *= 4;
} else if (next_due(last, t) - t > wait) {
wait = next_due(last, t) - t;
}
usleep((useconds_t)(wait * 1e6));
} }
} }
@@ -356,7 +408,7 @@ static int rec(double seconds, const char *dir, int pid) {
pthread_t writer; pthread_t writer;
pthread_create(&writer, NULL, ring_writer, NULL); pthread_create(&writer, NULL, ring_writer, NULL);
double start = now(); double start = now();
poll_frames(b, seconds, pid, rec_frame, NULL); poll_frames(b, seconds, pid, rec_frame, NULL, 300); // 0.3 ms: recordings' times
pthread_mutex_lock(&ring.mu); pthread_mutex_lock(&ring.mu);
ring.done = 1; ring.done = 1;
pthread_cond_signal(&ring.cv); pthread_cond_signal(&ring.cv);
@@ -383,6 +435,10 @@ static int rec(double seconds, const char *dir, int pid) {
#define SHARE_SLOTS 8 #define SHARE_SLOTS 8
#define SHARE_MAGIC 0x31434546u // "FEC1" #define SHARE_MAGIC 0x31434546u // "FEC1"
#define WANT_FRESH 3.0 // seconds a touch of the --want file lasts #define WANT_FRESH 3.0 // seconds a touch of the --want file lasts
// How often --share looks for frames, with a timer slack that lets the kernel group the
// wakeups: a frame reaches ft-eyes 1 to 1.5 ms after its camera starts the next, not 0.3.
#define SHARE_POLL_US 1000
#define SHARE_SLACK_NS 500000
typedef struct { typedef struct {
uint32_t magic, version, width, height, slots, entry_size; uint32_t magic, version, width, height, slots, entry_size;
@@ -462,6 +518,7 @@ static int share_loop(const char *path) {
.slots = SHARE_SLOTS, .entry_size = (uint32_t)esize}; .slots = SHARE_SLOTS, .entry_size = (uint32_t)esize};
signal(SIGINT, on_stop); signal(SIGINT, on_stop);
signal(SIGTERM, on_stop); signal(SIGTERM, on_stop);
if (prctl(PR_SET_TIMERSLACK, SHARE_SLACK_NS, 0, 0, 0) != 0) perror("PR_SET_TIMERSLACK");
fprintf(stderr, "ft-eyegrab: sharing frames in %s%s%s\n", path, want_path ? " while wanted by " : "", fprintf(stderr, "ft-eyegrab: sharing frames in %s%s%s\n", path, want_path ? " while wanted by " : "",
want_path ? want_path : ""); want_path ? want_path : "");
int pid = -1, idle = -1, missing = 0; int pid = -1, idle = -1, missing = 0;
@@ -486,7 +543,7 @@ static int share_loop(const char *path) {
if (idle != 0 || missing) fprintf(stderr, "ft-eyegrab: copying frames from eyetracking %d\n", pid); if (idle != 0 || missing) fprintf(stderr, "ft-eyegrab: copying frames from eyetracking %d\n", pid);
idle = 0, missing = 0; idle = 0, missing = 0;
h->tracker_pid = pid; h->tracker_pid = pid;
poll_frames(eye_buffer(), -1, pid, share_frame, wanted); poll_frames(eye_buffer(), -1, pid, share_frame, wanted, SHARE_POLL_US);
struct stat st; struct stat st;
char proc[64]; char proc[64];
snprintf(proc, sizeof proc, "/proc/%d", pid); snprintf(proc, sizeof proc, "/proc/%d", pid);
+42 -4
View File
@@ -50,6 +50,7 @@ import json
import math import math
import mmap import mmap
import os import os
import select
import socket import socket
import struct import struct
import sys import sys
@@ -58,7 +59,12 @@ import time
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
import numpy as np # One thread for numpy's BLAS and OpenMP, set before numpy loads: it would start one per core
# (8 here) for small arrays that never need them. eyes_pupil keeps OpenCV to one as well.
for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS"):
os.environ.setdefault(_var, "1")
import numpy as np # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import eyes_model # noqa: E402 import eyes_model # noqa: E402
@@ -82,6 +88,15 @@ GAP = 3.0 # s without frames: the headset was off, and may sit diffe
CLICK_BEFORE = 0.3 # a click's frames: the 300 ms before it (like the probe's fixation) CLICK_BEFORE = 0.3 # a click's frames: the 300 ms before it (like the probe's fixation)
CALIB_MIN = 15 # frames an eye needs in a calibration dot's window CALIB_MIN = 15 # frames an eye needs in a calibration dot's window
CALIB_SPREAD = 4.0 # px: more than this and the eye moved during the dot CALIB_SPREAD = 4.0 # px: more than this and the eye moved during the dot
# Waiting for frames. They come only as a counter in shared memory, so there's nothing to block
# on: sleep until a camera's next frame is due, then look every POLL. ft-eyegrab passes each one
# on within a ms or two of when its camera starts the one after, so they arrive 11.1 ms apart,
# give or take that.
PERIOD = 1 / 90 # s between a camera's frames
EARLY = 0.002 # start looking this long before a frame is due
POLL = 0.001 # then this often until it comes
STALLED = 0.1 # s without a frame: that camera stopped (headset off), and isn't waited for
IDLE_POLL = 0.02 # how often to look while both are stopped
class Cams: class Cams:
@@ -352,7 +367,7 @@ def wait_for_cams(sock, tracker, want):
return Cams() return Cams()
except (OSError, ValueError, RuntimeError): except (OSError, ValueError, RuntimeError):
serve(sock, tracker) serve(sock, tracker)
time.sleep(0.2) select.select([sock], [], [], 0.2)
def serve(sock, tracker): def serve(sock, tracker):
@@ -372,7 +387,24 @@ def serve(sock, tracker):
pass pass
def below_steamvr():
"""Nice 10 and SCHED_BATCH, for this thread and those it starts. ft-eyes runs in the dev
container's podman scope, where the gaze service's unit doesn't reach it, so it ran at
nice 0 on the cores vrcompositor and vrserver use. Batch also lets a waking ft-eyes wait
for the running task's turn instead of taking the core: a frame a few ms late costs the
gaze little, a late compositor frame costs a dropped frame in the headset."""
try:
os.setpriority(os.PRIO_PROCESS, 0, max(os.getpriority(os.PRIO_PROCESS, 0), 10))
except OSError as e:
print(f"ft-eyes: nice: {e}", file=sys.stderr, flush=True)
try:
os.sched_setscheduler(0, os.SCHED_BATCH, os.sched_param(0))
except (OSError, AttributeError) as e:
print(f"ft-eyes: SCHED_BATCH: {e}", file=sys.stderr, flush=True)
def main(): def main():
below_steamvr()
verbose = "-v" in sys.argv verbose = "-v" in sys.argv
if "--watch-stdin" in sys.argv: if "--watch-stdin" in sys.argv:
# Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our # Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our
@@ -398,6 +430,7 @@ def main():
print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True) print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True)
seen = [cams.count(0), cams.count(1)] seen = [cams.count(0), cams.count(1)]
report = time.monotonic() report = time.monotonic()
arrived = [0.0, 0.0] # when each camera's newest frame was seen (monotonic)
while True: while True:
serve(sock, tracker) serve(sock, tracker)
want() want()
@@ -407,6 +440,7 @@ def main():
if n == seen[c]: if n == seen[c]:
continue continue
seen[c] = n seen[c] = n
arrived[c] = time.monotonic()
got = cams.frame(c, n - 1) # only the newest: never fall behind got = cams.frame(c, n - 1) # only the newest: never fall behind
if got: if got:
eyes[c].feed(*got) eyes[c].feed(*got)
@@ -425,8 +459,6 @@ def main():
shifts += [float(v) for v in e.shift.value] shifts += [float(v) for v in e.shift.value]
pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan] pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan]
out.write(latest, (yaw, pitch), flags, per, shifts, pupils) out.write(latest, (yaw, pitch), flags, per, shifts, pupils)
else:
time.sleep(0.001)
now = time.monotonic() now = time.monotonic()
if now - report >= 5: if now - report >= 5:
if verbose: if verbose:
@@ -444,6 +476,12 @@ def main():
print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True) print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True)
cams = wait_for_cams(sock, tracker, want) cams = wait_for_cams(sock, tracker, want)
seen = [cams.count(0), cams.count(1)] seen = [cams.count(0), cams.count(1)]
# Until the next frame is due (a command on the socket wakes us sooner).
wait = IDLE_POLL
for c in (0, 1):
if now - arrived[c] < STALLED:
wait = min(wait, max(arrived[c] + PERIOD - EARLY - now, POLL))
select.select([sock], [], [], wait)
if __name__ == "__main__": if __name__ == "__main__":