Gaze: idle while the gaze isn't used

The gaze service ran ft-gaze and our own eye tracker all the time: with gaze mode off, ft-eyes
still took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. Now ft-gaze
and our tracker run only while gaze mode is on and someone wears the headset, while a check or
the calibration is open or asked for, or under a "wake" lease, which the Gaze page of Frametop
Input Settings renews while it's open. 30 s after the last use they stop, and the frame grabber
idles with our tracker.

- The pointer helper answers "gaze ? headset" with worn|away (SteamVR's activity level for the
  headset); an older helper answers it as before, and the service then goes by gaze mode alone.
- A quick check, calibration, or fit check asked for while idle wakes the tracker and opens once
  it sends; the automatic calibration waits quietly while it starts.
- Status has "awake" and "idle" (why), and the Gaze page shows it.
- gaze/test/idle-test.py runs the service with a fake helper and ft-gaze, offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-03 08:32:49 -06:00
1 parent 8aaf7db05a
commit 5b08e43a5d
9 files changed
+306 -20

No files matched your search

+1 -1
View File
@@ -191,7 +191,7 @@ Staying awake while charging uses Steam's own setting rather than a logind sleep
## Pausing for VR games ## Pausing for VR games
Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service runs at full rate whether gaze mode is on or not. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are. Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service ran at full rate whether gaze mode was on or not; now it idles while the gaze isn't used (gaze/README.md), and pausing stops it outright. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are.
- A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not, so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together. - A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not, so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together.
- The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started. - The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started.
+1 -1
View File
@@ -229,7 +229,7 @@ The state outlives a relay restart, in `/run/user/UID/frametop-pause.json`. A St
## Gaze pointer (experimental) ## Gaze pointer (experimental)
In gaze mode the 3D mouse's pointer goes where you look, and the mouse or the keyboard does the last bit. It needs the gaze service, which `install.sh` offers (yes by default) and `gaze/run.sh install` installs on its own: it builds it and runs `gaze/ft-gazed` as `frametop-gaze.service`, which starts with SteamVR. Turn gaze mode on with the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, `POINTER_GAZE=1`, or a button or key combination mapped to Gaze pointer on/off. In gaze mode the 3D mouse's pointer goes where you look, and the mouse or the keyboard does the last bit. It needs the gaze service, which `install.sh` offers (yes by default) and `gaze/run.sh install` installs on its own: it builds it and runs `gaze/ft-gazed` as `frametop-gaze.service`, which starts with SteamVR. The service idles while the gaze isn't used: its eye tracker reader and our own eye tracker run only while gaze mode is on and someone wears the headset, while a check or the calibration runs, or while the Gaze page of Frametop Input Settings is open, and stop 30 seconds after ([gaze/README.md](../gaze/README.md)). Turn gaze mode on with the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, `POINTER_GAZE=1`, or a button or key combination mapped to Gaze pointer on/off.
- Meta+J left-clicks and Meta+K right-clicks where you look. A quick tap clicks where the dot was at the press. Hold instead, and the dot stays put in your view: turn your head until it's on what you meant, and let go to click there. Held still for `POINTER_GAZE_HOLD` (0.5 s), the press becomes a real one, and your head drags. Meta+K with Meta+J held presses where the dot is now, to drag from there, and a second Meta+K during that drag (a double Meta+K) pans and tilts what you're dragging while it's held. - Meta+J left-clicks and Meta+K right-clicks where you look. A quick tap clicks where the dot was at the press. Hold instead, and the dot stays put in your view: turn your head until it's on what you meant, and let go to click there. Held still for `POINTER_GAZE_HOLD` (0.5 s), the press becomes a real one, and your head drags. Meta+K with Meta+J held presses where the dot is now, to drag from there, and a second Meta+K during that drag (a double Meta+K) pans and tilts what you're dragging while it's held.
- The mouse's buttons work the same way, with the mouse steering instead of your head (`POINTER_GAZE_MOUSE=precision`, the default): the right button with the left held starts a drag, and a double right click pans and tilts what you're dragging. With `POINTER_GAZE_MOUSE_MOVE=held`, the default, the mouse only corrects: while the gaze has the pointer, moving it does nothing unless a button is held. `free` lets the mouse take the pointer any time. With the gaze stale for a second, in a game, or with the headset off, the mouse works as usual. - The mouse's buttons work the same way, with the mouse steering instead of your head (`POINTER_GAZE_MOUSE=precision`, the default): the right button with the left held starts a drag, and a double right click pans and tilts what you're dragging. With `POINTER_GAZE_MOUSE_MOVE=held`, the default, the mouse only corrects: while the gaze has the pointer, moving it does nothing unless a button is held. `free` lets the mouse take the pointer any time. With the gaze stale for a second, in a game, or with the headset off, the mouse works as usual.
+1
View File
@@ -35,6 +35,7 @@ Gaze as an input method for the whole desktop, without replacing anything of Ste
- **Learning from nudges:** if the mouse took the pointer from the gaze and moved it (0.2 degrees or more, and the correction within `POINTER_GAZE_NUDGE_MAX`: 55 degrees by default, half of the 109 the headset shows across, and 1 to 110; the same limit for mouse, keyboard, and pinch clicks) before you clicked, or you dragged a held press that far, you were nudging it onto what you looked at. The helper sends that as a lesson, from the raw gaze when the mouse took over to where you clicked, and ft-gazed learns it. So using it is what calibrates it. The raw gaze is one ft-gazed sent, so it also finds when that look was, and what each eye read then. With SteamVR, each eye learns its own error. With our tracker, the look goes to it as a click, like the probe's, and it relearns how the headset sits on your face. After the headset was off, your first nudge and click there resets that (the quick check's dot does the same). A correction past `POINTER_GAZE_NUDGE_MAX` isn't learned: the helper asks ft-gazed for the quick check instead ("recheck", after its 2-minute cooldown). Tested on our tracker's 409 clicks since its Sep 29 calibration: a one-dot check set from any one of them put the next 2 minutes' clicks within 15 degrees (99% within 4.2) and the next 10 minutes' within 25 (the far ones after the headset moved), so the check gets back well under it. The limit used to be 8 degrees, and live on 2026-10-01 our tracker was 12 off after the headset went on, so every correction was dropped. One lesson moves the whole correction by only a third of what it measured (more near where it was taken), since in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. `ft-gazectl status` shows the lessons, and `ft-gazectl forget` drops them. - **Learning from nudges:** if the mouse took the pointer from the gaze and moved it (0.2 degrees or more, and the correction within `POINTER_GAZE_NUDGE_MAX`: 55 degrees by default, half of the 109 the headset shows across, and 1 to 110; the same limit for mouse, keyboard, and pinch clicks) before you clicked, or you dragged a held press that far, you were nudging it onto what you looked at. The helper sends that as a lesson, from the raw gaze when the mouse took over to where you clicked, and ft-gazed learns it. So using it is what calibrates it. The raw gaze is one ft-gazed sent, so it also finds when that look was, and what each eye read then. With SteamVR, each eye learns its own error. With our tracker, the look goes to it as a click, like the probe's, and it relearns how the headset sits on your face. After the headset was off, your first nudge and click there resets that (the quick check's dot does the same). A correction past `POINTER_GAZE_NUDGE_MAX` isn't learned: the helper asks ft-gazed for the quick check instead ("recheck", after its 2-minute cooldown). Tested on our tracker's 409 clicks since its Sep 29 calibration: a one-dot check set from any one of them put the next 2 minutes' clicks within 15 degrees (99% within 4.2) and the next 10 minutes' within 25 (the far ones after the headset moved), so the check gets back well under it. The limit used to be 8 degrees, and live on 2026-10-01 our tracker was 12 off after the headset went on, so every correction was dropped. One lesson moves the whole correction by only a third of what it measured (more near where it was taken), since in the first live test one 6 degree lesson moved everything and put the next target 7 degrees off. `ft-gazectl status` shows the lessons, and `ft-gazectl forget` drops them.
- **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow. The full calibration (Calibrate on the Gaze page, or by itself whenever gaze mode is on without one and your eyes are seen) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. A dot that isn't taken says why, on an orange line over the instructions: with SteamVR's tracker, what dropped most of that look's samples (an eye lost, a blink, the two eyes disagreeing); with ours, its reply (an eye seen in too few frames, or moving). A dot gets two tries, then it's skipped. A calibration left with under two thirds of its dots fails and names the most common reason, as the Gaze page does after it. A click that has taken nothing after 1.5 s says what it waits for: the gaze to hold still, or an eye tracker that isn't sending. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. One that closes otherwise unfinished (ignored for 2 minutes, too few dots) opens again after the headset comes off and on. Why gaze mode, on, can't work yet goes in the service's status as `checks.problem`, which the Gaze page shows under the Gaze pointer switch. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`. - **Checks and calibration in the headset** (`gaze/gazecheck.py`, shown by `gaze/panel/ft-gazepanel`, a panel fixed to the headset that ft-gazed runs): a one-dot quick check opens when you put the headset on (SteamVR's tracker sees your eyes for 3 s after none for 3 s; its "HMD on" log line can't say, since it repeats every minute or so and can stay on for hours with nobody in the headset), when our tracker asks for a click (its "reseat", when the headset may sit differently), at most once every 2 minutes, and from Quick check on the Gaze page. Look at the dot: it takes your gaze once it has held still for 0.6 s (the steadiness counts, not where the tracker puts it, so it works however far off it is), or at once with a left click or Meta+J; a right click or Meta+K closes it, and ignoring it changes nothing. It also runs when a click's correction was past `POINTER_GAZE_NUDGE_MAX`. The dot is still and the ring fills in quarters, so the panel is drawn again only a few times per dot. If the first 3 lessons after it are still over 2 degrees off, five dots follow. The full calibration (Calibrate on the Gaze page, or by itself whenever gaze mode is on without one and your eyes are seen) is the probe's: three rounds, dark, medium and bright, of the middle and a ring around it, in a panel 64 degrees wide, with Frametop's screens hidden. Its dots (and the five-dot check's) wait for a click: look at the dot and left click or press Meta+J, and the gaze held still up to then is taken. A dot that isn't taken says why, on an orange line over the instructions: with SteamVR's tracker, what dropped most of that look's samples (an eye lost, a blink, the two eyes disagreeing); with ours, its reply (an eye seen in too few frames, or moving). A dot gets two tries, then it's skipped. A calibration left with under two thirds of its dots fails and names the most common reason, as the Gaze page does after it. A click that has taken nothing after 1.5 s says what it waits for: the gaze to hold still, or an eye tracker that isn't sending. Capturing whenever the gaze held still sometimes took a look that wasn't on the dot. The panel draws into three shared buffers SteamVR imported once, as Frametop's keyboard does: uploading each picture anew (SetOverlayRaw) flickered, and in one live test left the headset showing an old picture. Quitting it while there's still no calibration turns gaze mode off; turning it on again reopens it. One that closes otherwise unfinished (ignored for 2 minutes, too few dots) opens again after the headset comes off and on. Why gaze mode, on, can't work yet goes in the service's status as `checks.problem`, which the Gaze page shows under the Gaze pointer switch. For our tracker a check is a click and the calibration is its own (calib-point per dot); for SteamVR's, a check is a lesson for each eye and the calibration replaces calibration.json, and the lessons start over. Checks go to `checks.jsonl`.
- Nothing writes to SteamVR, its eye tracker, or its files: ft-gaze maps the eye tracker's shared memory read-only. With no fresh gaze (a blink, the service stopped, the headset off), the pointer stays where it is, and the mouse works as always. - Nothing writes to SteamVR, its eye tracker, or its files: ft-gaze maps the eye tracker's shared memory read-only. With no fresh gaze (a blink, the service stopped, the headset off), the pointer stays where it is, and the mouse works as always.
- **Idle while the gaze isn't used:** ft-gaze and our own tracker run only while gaze mode is on and someone wears the headset (the pointer helper says both: SteamVR drops the headset's activity level as soon as it comes off), while a check or the calibration is open or asked for, or while the Gaze page of Frametop Input Settings is open (it renews a `wake` lease). 30 seconds after the last use they stop, and our frame grabber goes idle with our tracker. With our tracker, that saves over half a core: on 2026-10-02, with gaze mode off, ft-eyes took about 60% of a core, and ft-eyegrab, ft-gaze and ft-gazed 3 to 4% each. A check asked for while it idles starts the tracker and opens once it sends. When the gaze is used again, it takes a few seconds to come back, and our tracker's first click re-seats it, as after the headset was off: so the quick check opens when gaze mode comes on after the service idled, as it does when you put the headset on. `ft-gazectl status` says `"awake"`, and `"idle"` says why it isn't. A stand that covers the proximity sensor makes the headset seem worn, so with gaze mode on it doesn't idle there.
Lessons are logged to `pointer-lessons.jsonl`: the raw gaze, the true direction, the correction at the time, and how far off it was. Lessons are logged to `pointer-lessons.jsonl`: the raw gaze, the true direction, the correction at the time, and how far off it was.
+73 -5
View File
@@ -63,9 +63,18 @@ and the error moves): lessons from before count less then, so the first few afte
relearn the offset. relearn the offset.
Our own tracker (gaze/tracker/ft-eyes) runs here too, in the dev container, while Our own tracker (gaze/tracker/ft-eyes) runs here too, in the dev container, while
GAZE_TRACKER=own or the gaze probe asks for it ("eyes SECONDS", a lease the probe renews). It GAZE_TRACKER=own and the gaze is in use (below), or the gaze probe asks for it ("eyes SECONDS",
reads the eye-camera frames the root service frametop-eyegrab copies (gaze/tracker/install.sh), a lease the probe renews). It reads the eye-camera frames the root service frametop-eyegrab
which copies them only while ft-eyes runs. copies (gaze/tracker/install.sh), which copies them only while ft-eyes runs.
Idle: ft-gaze and our own tracker run only while the gaze is in use: gaze mode on with someone
wearing the headset (the pointer helper says both: "gaze ? headset" -> "ok on|off worn|away"), a
check or calibration open or asked for, or a "wake" lease (the Gaze page of Frametop Input
Settings renews one while it's open). IDLE_AFTER after the last use they stop, and the frame
grabber goes idle with our tracker; with our tracker, that was over half a core with gaze mode
off. A check asked for while idle waits for the tracker to start (at most WAKE_SETTLE). Our
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.
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,
@@ -83,6 +92,8 @@ Control socket: abstract unix datagram "@ft_gazed":
reload read calibration.json and the settings again reload read calibration.json and the settings again
eyes <seconds> keep our own tracker running that much longer (at most 120), eyes <seconds> keep our own tracker running that much longer (at most 120),
whatever GAZE_TRACKER says: the probe's lease. Reply: "ok" whatever GAZE_TRACKER says: the probe's lease. Reply: "ok"
wake <seconds> keep the gaze running (not idle) that much longer (at most 120),
whatever gaze mode says: Input Settings' Gaze page. Reply: "ok"
quickcal the one-dot check now (the calibration if there's none) quickcal the one-dot check now (the calibration if there's none)
calibrate the full calibration in the panel calibrate the full calibration in the panel
fitcheck the headset fit check in the panel fitcheck the headset fit check in the panel
@@ -140,6 +151,9 @@ RETRY = 3.0 # seconds before starting ft-gaze again
EYES_RETRY = 10.0 # seconds before starting ft-eyes again after it stopped on its own EYES_RETRY = 10.0 # seconds before starting ft-eyes again after it stopped on its own
EYES_LEASE_MAX = 120.0 EYES_LEASE_MAX = 120.0
SETTLE = 0.3 # seconds after an eye is found again before the fallback learns from it SETTLE = 0.3 # seconds after an eye is found again before the fallback learns from it
IDLE_AFTER = 30.0 # seconds after the gaze was last in use before ft-gaze and our tracker stop
WAKE_SETTLE = 15.0 # seconds after waking that the tracker may take to start sending
WAKE_MAX = 120.0
KEYBOARD_PITCH = -20.0 # degrees: gaze under this, on no screen, is a look at the keyboard KEYBOARD_PITCH = -20.0 # degrees: gaze under this, on no screen, is a look at the keyboard
@@ -230,6 +244,10 @@ class Service:
self.eyes_proc = None # ft-eyes, while it runs self.eyes_proc = None # ft-eyes, while it runs
self.eyes_until = 0.0 # the probe's lease (monotonic time) self.eyes_until = 0.0 # the probe's lease (monotonic time)
self.eyes_restart_at = 0.0 self.eyes_restart_at = 0.0
self.awake = False # ft-gaze (and our tracker) run: the gaze is in use (see the top)
self.idle_at = 0.0 # idle from then, unless it's in use again first
self.woke_at = 0.0
self.wake_until = 0.0 # a "wake" lease
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK) self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
self.sock.bind(ME) self.sock.bind(ME)
@@ -399,6 +417,46 @@ class Service:
log(f"lesson log: {e}") log(f"lesson log: {e}")
return rec return rec
# --- Idle (see the top) ---
def use_reason(self):
"""Why the gaze is in use now, or None."""
c = self.checks
if c.gaze_on and c.headset is not False:
return "gaze mode is on"
if c.active or c.pending:
return "a check"
if time.monotonic() < self.wake_until:
return "asked to stay awake"
return None
def idle_reason(self):
c = self.checks
if c.gaze_on is None:
return "the pointer helper isn't answering (SteamVR not running?)"
if c.gaze_on and c.headset is False:
return "nobody is wearing the headset"
return "gaze mode is off"
def waking(self):
"""Idle, or awake too briefly for the tracker to be sending yet."""
return not self.awake or time.monotonic() - self.woke_at < WAKE_SETTLE
def update_awake(self):
now = time.monotonic()
why = self.use_reason()
if why:
self.idle_at = now + IDLE_AFTER
if why and not self.awake:
self.awake, self.woke_at, self.restart_at = True, now, 0.0
log(f"awake: {why}")
elif not why and self.awake and now >= self.idle_at:
self.awake = False
log(f"idle: {self.idle_reason()}; ft-gaze and our tracker stop until the gaze is used again")
self.stop_helper()
if self.eyes_proc and not self.eyes_wanted():
self.stop_eyes()
# --- ft-gaze --- # --- ft-gaze ---
def start_helper(self): def start_helper(self):
@@ -441,7 +499,7 @@ class Service:
self.proc = None self.proc = None
def eyes_wanted(self): def eyes_wanted(self):
return (self.tracker == "own" and not self.override) or time.monotonic() < self.eyes_until return (self.tracker == "own" and not self.override and self.awake) or time.monotonic() < self.eyes_until
def start_eyes(self): def start_eyes(self):
"""Our own tracker, in the dev container, with build/venv's numpy and OpenCV. Like """Our own tracker, in the dev container, with build/venv's numpy and OpenCV. Like
@@ -700,6 +758,14 @@ class Service:
reply = "ok" reply = "ok"
except ValueError: except ValueError:
reply = "error bad seconds" reply = "error bad seconds"
elif words[:1] == ["wake"] and len(words) == 2:
try:
secs = min(max(float(words[1]), 0.0), WAKE_MAX)
self.wake_until = max(self.wake_until, time.monotonic() + secs)
self.update_awake()
reply = "ok"
except ValueError:
reply = "error bad seconds"
elif words[:1] == ["reload"]: elif words[:1] == ["reload"]:
self.load_settings() self.load_settings()
self.load_calibration() self.load_calibration()
@@ -750,6 +816,7 @@ class Service:
st.update(calibration_samples=cal.get("dots", 0), calibration_made=cal.get("made"), st.update(calibration_samples=cal.get("dots", 0), calibration_made=cal.get("made"),
lessons=max(len(m) for m in w.misses), own_running=bool(own), lessons=max(len(m) for m in w.misses), own_running=bool(own),
own_reseat=any(e.get("reseat") for e in own.get("eyes", {}).values())) own_reseat=any(e.get("reseat") for e in own.get("eyes", {}).values()))
st.update(awake=self.awake, idle=None if self.awake else self.idle_reason())
st.update(eyes_process=self.eyes_proc is not None, eyegrab=EYES_CAMS.exists(), st.update(eyes_process=self.eyes_proc is not None, eyegrab=EYES_CAMS.exists(),
tracker_setting=self.tracker_setting, own_installed=own_installed()) tracker_setting=self.tracker_setting, own_installed=own_installed())
st.update({"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2) st.update({"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2)
@@ -770,6 +837,7 @@ class Service:
if mtime(CONF) != self.conf_mtime or (self.tracker_setting == "auto" if mtime(CONF) != self.conf_mtime or (self.tracker_setting == "auto"
and own_installed() != (self.tracker == "own")): and own_installed() != (self.tracker == "own")):
self.load_settings() self.load_settings()
self.update_awake()
want = self.eyes_wanted() want = self.eyes_wanted()
if want and not self.eyes_proc and time.monotonic() >= self.eyes_restart_at: if want and not self.eyes_proc and time.monotonic() >= self.eyes_restart_at:
self.start_eyes() self.start_eyes()
@@ -790,7 +858,7 @@ class Service:
next_verbose = time.monotonic() + 5 next_verbose = time.monotonic() + 5
while self.running: while self.running:
now = time.monotonic() now = time.monotonic()
if not self.proc and now >= self.restart_at: if self.awake and not self.proc and now >= self.restart_at:
self.start_helper() self.start_helper()
for key, _ in self.sel.select(timeout=0.05 if self.checks.active else 0.5): for key, _ in self.sel.select(timeout=0.05 if self.checks.active else 0.5):
if key.data == "control": if key.data == "control":
+49 -11
View File
@@ -10,7 +10,9 @@ directions: look at each one.
once every QUICK_COOLDOWN, and on "quickcal" (Frametop Input Settings, or a mouse once every QUICK_COOLDOWN, and on "quickcal" (Frametop Input Settings, or a mouse
button or key combination mapped to Gaze quick check), and when a click's correction button or key combination mapped to Gaze quick check), and when a click's correction
was past POINTER_GAZE_NUDGE_MAX (55 degrees; the helper's "recheck"): the tracker is was past POINTER_GAZE_NUDGE_MAX (55 degrees; the helper's "recheck"): the tracker is
far off. Ignored, it closes after QUICK_TIMEOUT and changes nothing. far off. Ignored, it closes after QUICK_TIMEOUT and changes nothing. The headset going
on is seen only while the gaze service is awake (gaze mode on, someone wearing it: see
ft-gazed), so it also opens when gaze mode comes on after the service idled.
five the middle and four around it, when the first FIVE_COUNT lessons after a quick check five the middle and four around it, when the first FIVE_COUNT lessons after a quick check
were all over FIVE_LIMIT degrees off: the quick check didn't fix it. were all over FIVE_LIMIT degrees off: the quick check didn't fix it.
full the calibration, as the gaze probe's: three rounds, dark, medium and bright (pupil full the calibration, as the gaze probe's: three rounds, dark, medium and bright (pupil
@@ -30,6 +32,9 @@ directions: look at each one.
adjust the headset. A left click or Meta+J runs its guided check (dots, then looks adjust the headset. A left click or Meta+J runs its guided check (dots, then looks
down, up, left and right); a right click or Meta+K closes it, as does FIT_TIMEOUT. down, up, left and right); a right click or Meta+K closes it, as does FIT_TIMEOUT.
A check asked for while the gaze service idles (quickcal, calibrate, fitcheck) wakes it and
waits until the tracker sends, at most ft-gazed's WAKE_SETTLE; then it opens, or logs why not.
The quick check's dot captures itself: from CHECK_SETTLE after it shows (the eyes getting The quick check's dot captures itself: from CHECK_SETTLE after it shows (the eyes getting
there), once the gaze has held within CHECK_SPREAD for CHECK_WINDOW (the probe's max spread and there), once the gaze has held within CHECK_SPREAD for CHECK_WINDOW (the probe's max spread and
capture time). It's the gaze holding still that counts, not where the tracker puts it, so it capture time). It's the gaze holding still that counts, not where the tracker puts it, so it
@@ -209,7 +214,9 @@ class Checks:
sel.register(self.out, selectors.EVENT_READ, "checks") sel.register(self.out, selectors.EVENT_READ, "checks")
self.check = None self.check = None
self.gaze_on = None self.gaze_on = None
self.headset = None # someone wears it (the helper's "worn"), False "away", None unknown
self.gaze_heard = 0.0 self.gaze_heard = 0.0
self.pending = None # (command words, when): asked for while the gaze service idled
self.full_armed = True # gaze mode on without a calibration opens the full one (need_full) 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_blocked = None # why it can't open now
self.full_retry_at = 0.0 self.full_retry_at = 0.0
@@ -294,17 +301,22 @@ class Checks:
pass pass
def on_readable(self): def on_readable(self):
"""Replies on our socket: the helper's "ok on|off" to "gaze ?"; the panel's are dropped.""" """Replies on our socket: the helper's "ok on|off [worn|away]" to "gaze ? headset" (an
older helper leaves the headset out); the panel's are dropped."""
while True: while True:
try: try:
data = self.out.recv(4096).decode("utf-8", "replace") words = self.out.recv(4096).decode("utf-8", "replace").split()
except (BlockingIOError, OSError): except (BlockingIOError, OSError):
return return
if data in ("ok on", "ok off"): if words[:1] == ["ok"] and len(words) in (2, 3) and words[1] in ("on", "off"):
on = data == "ok on" on = words[1] == "on"
was, self.gaze_on, self.gaze_heard = self.gaze_on, on, time.monotonic() headset = None if len(words) == 2 else words[2] == "worn"
if on and was is False: was = (self.gaze_on, self.headset)
self.gaze_on, self.headset, self.gaze_heard = on, headset, time.monotonic()
if on and was[0] is False:
self.on_gaze_on() self.on_gaze_on()
if was != (on, headset):
self.svc.update_awake()
# --- State --- # --- State ---
@@ -341,6 +353,8 @@ class Checks:
self.full_blocked = None self.full_blocked = None
return return
now = time.monotonic() now = time.monotonic()
if not self.can_run() and self.svc.waking():
return # the tracker is still starting (the service idled)
if not self.can_run(): if not self.can_run():
why = ("the eye tracker isn't sending" if now - self.svc.last_sample >= 2 why = ("the eye tracker isn't sending" if now - self.svc.last_sample >= 2
else "no eyes seen (is the headset on?)") else "no eyes seen (is the headset on?)")
@@ -490,6 +504,8 @@ class Checks:
def on_sample(self, s): def on_sample(self, s):
self.sample_at = time.monotonic() self.sample_at = time.monotonic()
if self.pending:
self.run_pending()
unc = (s["src"].get("mmap1") or {}).get("unc") unc = (s["src"].get("mmap1") or {}).get("unc")
if unc and min(unc) <= EYE_LOST: if unc and min(unc) <= EYE_LOST:
self.seen_at = time.monotonic() self.seen_at = time.monotonic()
@@ -742,9 +758,28 @@ class Checks:
# --- From the service --- # --- From the service ---
def command(self, words): def run_pending(self):
"""A check asked for while the service idled: once the tracker sends (and our tracker has
said whether it's calibrated), or WAKE_SETTLE after waking, when its error is the real one."""
svc = self.svc
words, _ = self.pending
ready = (time.monotonic() - svc.last_sample < 2 if words[0] == "fitcheck" else self.can_run()) \
and (svc.kind != "own" or self.calibrated() is not None)
if not ready and svc.waking():
return
self.pending = None
reply = self.command(words, queue=False)
if reply != "ok":
log(f"{words[0]}, asked for while idle: {reply.removeprefix('error ')}")
def command(self, words, queue=True):
"""quickcal, calibrate, calaccept, calquit -> a reply.""" """quickcal, calibrate, calaccept, calquit -> a reply."""
cmd = words[0] cmd = words[0]
if cmd in ("quickcal", "calibrate", "fitcheck") and queue and self.svc.waking() and not self.check:
# The tracker isn't running (or only just started): wake it, and do this once it sends.
self.pending = (words, time.monotonic())
self.svc.update_awake()
return "ok waking the eye tracker first"
if cmd == "quickcal": if cmd == "quickcal":
return self.start("full" if self.calibrated() is False else "quick", "asked for") return self.start("full" if self.calibrated() is False else "quick", "asked for")
if cmd == "calibrate": if cmd == "calibrate":
@@ -820,9 +855,11 @@ class Checks:
now = time.monotonic() now = time.monotonic()
if not self.panel_proc and now >= self.panel_restart_at: if not self.panel_proc and now >= self.panel_restart_at:
self.start_panel() self.start_panel()
self.to_helper("gaze ?") self.to_helper("gaze ? headset")
if now - self.gaze_heard > 5: if now - self.gaze_heard > 5:
self.gaze_on = None # the helper isn't answering self.gaze_on = self.headset = None # the helper isn't answering
if self.pending:
self.run_pending()
if self.check: if self.check:
self.to_helper("calpanel 1") self.to_helper("calpanel 1")
if not self.eyes_seen(AWAY_MIN): if not self.eyes_seen(AWAY_MIN):
@@ -844,7 +881,8 @@ class Checks:
def status(self): def status(self):
c = self.check c = self.check
st = {"check": None, "gaze_mode": self.gaze_on, "calibrated": self.calibrated(), "eyes": self.eyes_seen(), st = {"check": None, "gaze_mode": self.gaze_on, "headset_worn": self.headset, "pending": self.pending[0][0]
if self.pending else None, "calibrated": self.calibrated(), "eyes": self.eyes_seen(),
"problem": self.problem(), "problem": self.problem(),
"panel": self.panel_proc is not None, "panel": self.panel_proc is not None,
"last_quick_s": round(time.monotonic() - self.last_quick) if self.last_quick else None} "last_quick_s": round(time.monotonic() - self.last_quick) if self.last_quick else None}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Offline test of the gaze service idling (gaze/ft-gazed, gaze/gazecheck.py): ft-gaze runs only
while the gaze is in use, and a check asked for while it idles waits for the tracker.
Runs ft-gazed's Service with its sockets renamed, a fake pointer helper (answers "gaze ?
headset" as the test says), and a fake ft-gaze (prints samples, quits when its stdin closes).
SteamVR's tracker is the one in use, so our own isn't started; the panel isn't either. Nothing
reaches the live gaze service, the pointer helper, or SteamVR, so it's safe next to them.
gaze/test/idle-test.py
"""
import importlib.machinery
import importlib.util
import os
import socket
import subprocess
import sys
import tempfile
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
GAZE = os.path.join(HERE, "..")
sys.path.insert(0, GAZE)
loader = importlib.machinery.SourceFileLoader("ftgazed", os.path.join(GAZE, "ft-gazed"))
gazed = importlib.util.module_from_spec(importlib.util.spec_from_loader("ftgazed", loader))
loader.exec_module(gazed)
import gazecheck # noqa: E402 (the module ft-gazed imported)
tag = f"ft_gaze_idle_test_{os.getpid()}"
gazed.ME = f"\0{tag}_gazed"
gazed.POINTER = gazecheck.POINTER = f"\0{tag}_helper"
gazecheck.SCREENS = f"\0{tag}_screens"
gazecheck.PANEL_PROG = gazed.REPO / "nonexistent-panel" # "isn't built": no panel
gazed.IDLE_AFTER, gazed.WAKE_SETTLE = 1.0, 3.0
gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0)
logs = []
gazed.log = gazecheck.log = lambda msg: logs.append(msg)
# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes.
tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-")
FAKE = os.path.join(tmp, "ft-gaze")
with open(FAKE, "w") as f:
f.write('''import json, select, sys, time
while True:
if select.select([sys.stdin], [], [], 1 / 90)[0] and not sys.stdin.read(1):
break
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],
"open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True)
''')
started = []
def start_helper(self):
"""ft-gaze, straight from here instead of the dev container."""
import selectors
self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
os.set_blocking(self.proc.stdout.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.stderr, selectors.EVENT_READ, "stderr")
self.buf = b""
started.append(time.monotonic())
gazed.Service.start_helper = start_helper
# The fake pointer helper.
helper_state = {"reply": "ok off worn"}
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
helper.bind(gazed.POINTER)
helper.settimeout(0.2)
def answer():
while True:
try:
data, addr = helper.recvfrom(512)
except socket.timeout:
continue
except OSError:
return
if data.startswith(b"gaze ?") and addr:
helper.sendto(helper_state["reply"].encode(), addr)
threading.Thread(target=answer, daemon=True).start()
svc = gazed.Service(None, False, gazed.POINTER)
threading.Thread(target=svc.run, daemon=True).start()
ctl = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
ctl.bind("")
ctl.settimeout(2)
def ask(cmd):
ctl.sendto(cmd.encode(), gazed.ME)
return ctl.recv(4096).decode()
failures = []
def check(label, got, want):
ok = got == want
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
if not ok:
failures.append(label)
def wait(cond, seconds):
end = time.monotonic() + seconds
while time.monotonic() < end:
if cond():
return True
time.sleep(0.05)
return cond()
time.sleep(2.5)
check("gaze mode off: idle, no ft-gaze", (svc.awake, svc.proc is None), (False, True))
check("status says why", ask("status").count('"idle": "gaze mode is off"'), 1)
helper_state["reply"] = "ok on worn"
check("gaze mode on, headset worn: awake within 2 s", wait(lambda: svc.awake and svc.proc is not None, 2.5), True)
check("samples come in", wait(lambda: svc.counts["samples"] > 20, 2), True)
helper_state["reply"] = "ok on away"
check("headset off: still awake for IDLE_AFTER", wait(lambda: not svc.awake, 0.5), False)
check("then idle, ft-gaze stopped", wait(lambda: not svc.awake and svc.proc is None, 3.5), True)
check("why: nobody wears it", ask("status").count('"idle": "nobody is wearing the headset"'), 1)
helper_state["reply"] = "ok on"
check("an older helper (no headset word): awake with gaze mode on", wait(lambda: svc.awake, 2.5), True)
helper_state["reply"] = "ok off worn"
check("gaze mode off again: idle", wait(lambda: not svc.awake and svc.proc is None, 4.5), True)
check("wake lease", ask("wake 2"), "ok")
check("wake: awake at once", (svc.awake, wait(lambda: svc.proc is not None, 1)), (True, True))
check("lease over (2 s + IDLE_AFTER): idle", wait(lambda: not svc.awake, 4.5), True)
time.sleep(0.5)
logs.clear()
count = len(started)
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("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)
check("nothing left pending", svc.checks.pending, None)
check("idle again after", wait(lambda: not svc.awake, 3), True)
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
svc.running = False
time.sleep(0.7)
os.remove(FAKE)
os.rmdir(tmp)
os._exit(1 if failures else 0)
+6
View File
@@ -1163,6 +1163,12 @@ class Backend(QObject):
else: else:
self.message.emit("The gaze service isn't running (frametop-gaze.service)", True) self.message.emit("The gaze service isn't running (frametop-gaze.service)", True)
@Slot()
def keepGazeAwake(self):
"""The Gaze page is open: the gaze service doesn't idle meanwhile (its status stays live,
and a check opens without waiting for the tracker). The page renews this every 10 s."""
self._send("wake 30", GAZED)
@Slot() @Slot()
def gazeQuickCheck(self): def gazeQuickCheck(self):
"""The gaze service's one-dot check, in the panel fixed to the headset.""" """The gaze service's one-dot check, in the panel fixed to the headset."""
+10 -1
View File
@@ -969,6 +969,14 @@ Kirigami.ApplicationWindow {
title: "Gaze" title: "Gaze"
header: DriverWarning {} header: DriverWarning {}
property var status: backend.gazeStatus property var status: backend.gazeStatus
// The gaze service idles while the gaze isn't used; open, this page keeps it going.
Timer {
running: true
repeat: true
interval: 10000
triggeredOnStart: true
onTriggered: backend.keepGazeAwake()
}
actions: [ actions: [
Kirigami.Action { Kirigami.Action {
text: "Quick check" text: "Quick check"
@@ -1202,7 +1210,8 @@ Kirigami.ApplicationWindow {
Controls.Label { Controls.Label {
Kirigami.FormData.label: "Service:" Kirigami.FormData.label: "Service:"
text: backend.gazeServiceRunning text: backend.gazeServiceRunning
? (gpage.status.ft_gaze ? "running" : "running, eye tracker reader restarting") ? (gpage.status.awake === false ? "idle: " + gpage.status.idle + " (it starts when the gaze is used)"
: gpage.status.ft_gaze ? "running" : "running, eye tracker reader starting")
: backend.gazeServiceInstalled ? "not running (frametop-gaze.service, starts with SteamVR)" : backend.gazeServiceInstalled ? "not running (frametop-gaze.service, starts with SteamVR)"
: "not installed: run gaze/run.sh install in the Frametop folder, in a terminal" : "not installed: run gaze/run.sh install in the Frametop folder, in a terminal"
color: backend.gazeServiceRunning ? Kirigami.Theme.textColor : Kirigami.Theme.negativeTextColor color: backend.gazeServiceRunning ? Kirigami.Theme.textColor : Kirigami.Theme.negativeTextColor
+7 -1
View File
@@ -267,7 +267,8 @@
// //
// Commands (datagrams on @ft_pointer_helper): show, hide, recenter, move <dyaw> <dpitch>, // Commands (datagrams on @ft_pointer_helper): show, hide, recenter, move <dyaw> <dpitch>,
// follow on|off|toggle (head follow, until the next restart or a change to POINTER_FOLLOW), // follow on|off|toggle (head follow, until the next restart or a change to POINTER_FOLLOW),
// gaze on|off|toggle|? (gaze mode, likewise with POINTER_GAZE; ? only asks), gz ... (the gaze, from ft-gazed), // gaze on|off|toggle|? (gaze mode, likewise with POINTER_GAZE; ? only asks, and "? headset" adds
// worn|away to the reply), gz ... (the gaze, from ft-gazed),
// reload (re-read the settings below), debug (toggle a twice-a-second state log), // reload (re-read the settings below), debug (toggle a twice-a-second state log),
// overlays (replies with the overlay list as JSON, see OverlayList), // overlays (replies with the overlay list as JSON, see OverlayList),
// vrbind/vrglobal/vrstatus (Frame controller buttons, see vrbuttons.h), // vrbind/vrglobal/vrstatus (Frame controller buttons, see vrbuttons.h),
@@ -1387,6 +1388,11 @@ int main() {
std::printf("gaze mode %s\n", gazeOn ? "on" : "off"); std::printf("gaze mode %s\n", gazeOn ? "on" : "off");
std::fflush(stdout); std::fflush(stdout);
} }
// "gaze ? headset" (the gaze service, which idles while nobody wears the headset)
// also says whether someone does.
if (std::strstr(arg, "headset"))
reply(gazeOn ? (headsetOff ? "ok on away" : "ok on worn") : (headsetOff ? "ok off away" : "ok off worn"));
else
reply(gazeOn ? "ok on" : "ok off"); reply(gazeOn ? "ok on" : "ok off");
continue; continue;
} }