mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 00:00:07 +02:00
Let the gaze pointer use our own eye tracker, and weight the eyes
Frametop Input Settings' Gaze page gets two settings, saved in frametop.conf and read again by ft-gazed when the file changes: Eye tracker (GAZE_TRACKER: SteamVR's or our own, frame-eyes' fe-trackd) and Eye bias (GAZE_EYE: auto, left, right). ft-gazed now combines the eyes, each calibrated on its own: SteamVR's set 2 eyes with the probe's Left eye and Right eye calibrations, or our tracker's eyes as they come. Without per-eye calibrations, or with --source, it keeps the older one-source path. A pointer nudge finds its look from the raw gaze the helper echoes back; with our tracker it goes to fe-trackd as a click. The bias leans instead of choosing (gazecal.EyeWeights): on 306 live clicks the eyes' errors partly cancelled, both together 0.65 deg off against 0.96 and 1.11 for either alone. Left or Right counts that eye twice; auto weights each eye by its RMS miss at its last 20 nudges, since the calibration's fit picked the wrong eye on SteamVR's test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3440ec8b90
commit
5565a25444
7 files changed
+518
-94
No files matched your search
+1
-1
@@ -102,7 +102,7 @@ pointer/helper/run.sh status | log | restart
|
||||
pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer
|
||||
```
|
||||
|
||||
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, and `POINTER_GAZE_SHOW`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper.
|
||||
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, and `POINTER_GAZE_SHOW`, and the gaze service's `GAZE_TRACKER` (SteamVR's eye tracker or our own) and `GAZE_EYE` (the eye bias). The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper (the gaze service reads its two again when the file changes).
|
||||
|
||||
## Frametop Input Settings
|
||||
|
||||
|
||||
+9
-3
@@ -18,9 +18,15 @@ gaze/ft-gazectl on # the pointer follows your gaze (off: the mouse al
|
||||
|
||||
Gaze as an input method for the whole desktop, without replacing anything of SteamVR's:
|
||||
|
||||
- `ft-gazed` (host Python, a user service: `gaze/run.sh install`) runs ft-gaze and corrects its gaze. It uses SteamVR's combined gaze (mmap set 1). When the tracker loses one eye (its variance for that eye jumps from about 0.001 to 0.02), the gaze comes from the other eye instead: that eye's own reading (set 2) plus what it usually reads against the combined gaze, learned while both eyes are seen, in 10 degree cells of where it looks. Set 1 keeps going on one eye too, but it holds the lost eye's yaw where it was, so the gaze moves half as far sideways as your eyes do. On a recording, one eye alone came out a median 0.8 degrees from both eyes' gaze over a steady look, a little more jittery. Looks down past the screens (under 20 degrees down, on no Frametop screen: a glance at the keyboard) aren't sent, so the pointer stays where it was instead of following you down, and eyes lost there aren't counted. It drops blinks (both eyes closing or lost), smooths with a fixation lock, and applies the calibration from the probe (`calibration.json`, reloaded when the probe changes it) plus what the pointer has learned since (`pointer-lessons.json`). It sends the result to the pointer helper 90 times a second. It follows SteamVR's eye tracking log, and when the headset goes back on (SteamVR starts its eye model over, and the error moves), older lessons count less, so the first few after relearn the offset.
|
||||
- `ft-gazed` (host Python, a user service: `gaze/run.sh install`) runs ft-gaze and corrects its gaze. Two settings on the Gaze page of Frametop Input Settings (`GAZE_TRACKER` and `GAZE_EYE` in `~/.config/frametop.conf`, read again when the file changes) pick whose eye tracking it uses and how it weights the eyes:
|
||||
- **Eye tracker:** SteamVR's (the default), or our own (Own tracker, from `~/Desktop/Projects/frame-eyes`, while its `tools/fe-live` runs). Ours keeps its own calibration: calibrate it in the probe with the tracker toggle on Own tracker. The gaze pointer's settings (hand back, nudges, hold to drag, the dot) are the pointer helper's, so they're the same with either.
|
||||
- **Eye bias:** Auto, Left, or Right. The gaze combines both eyes, each calibrated on its own, because their errors partly cancel: on 306 clicks with our tracker, the eyes' sideways errors were correlated -0.37, and both together were 0.65 degrees off (median) against 0.96 for the left eye alone and 1.11 for the right. So Left or Right leans instead of choosing: that eye counts twice as much as the other (0.03 degrees worse there toward the better eye, 0.13 toward the worse). Auto weights each eye by the inverse square of how far off it was at your last 20 nudges, once each eye has 5, and evenly before that. Each eye's miss is measured before that nudge teaches anything, so each is a fresh test. The calibration's own fit isn't used for this: on SteamVR's test of 2026-09-29, the calibration dots said the left eye was the better one, and new spots said the right. Either eye carries the gaze alone while the other is closed or lost.
|
||||
|
||||
With SteamVR, each eye is its own reading (set 2), corrected by its calibration from the probe (the Left eye and Right eye sources) plus what the pointer has taught that eye since. On that test, the two eyes each calibrated and averaged were 1.70 degrees off (median; mean 1.62) against 1.72 (mean 1.84) for SteamVR's combined gaze with its calibration. A calibration from before the probe had the eyes as sources, or `--source`, uses the older path. That path runs on SteamVR's combined gaze (mmap set 1), corrected as a whole. When the tracker loses one eye (its variance for that eye jumps from about 0.001 to 0.02), the gaze comes from the other eye instead: that eye's own reading (set 2) plus what it usually reads against the combined gaze, learned while both eyes are seen, in 10 degree cells of where it looks. Set 1 keeps going on one eye too, but it holds the lost eye's yaw where it was, so the gaze moves half as far sideways as your eyes do. On a recording, one eye alone came out a median 0.8 degrees from both eyes' gaze over a steady look, a little more jittery.
|
||||
|
||||
Looks down past the screens (under 20 degrees down, on no Frametop screen: a glance at the keyboard) aren't sent, so the pointer stays where it was instead of following you down, and eyes lost there aren't counted. It drops blinks (both eyes closing or lost), smooths with a fixation lock, and sends the result to the pointer helper 90 times a second. It follows SteamVR's eye tracking log, and when the headset goes back on (SteamVR starts its eye model over, and the error moves), older lessons count less, so the first few after relearn the offset.
|
||||
- The pointer helper's **gaze mode** (off by default: the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, `POINTER_GAZE=1` in `~/.config/frametop.conf`, or a mouse or controller button mapped to "Gaze pointer on/off") works like MAGIC pointing (Zhai et al., 1999). The pointer goes where you look. Move the mouse and it's the mouse's, from where the gaze put it, for the last bit. Look well away (5 degrees) and the gaze takes it back. A press isn't sent at once: the pointer stops where the gaze put it, and if that's wrong, drag it onto what you meant with the button still held; the click happens where you let go. To drag something, hold the press still for half a second first (`POINTER_GAZE_HOLD`), then move. Outside games the pointer stays on while gaze mode is on. The dot only shows while the mouse moves it, while a press is held, and as a pulse when you click.
|
||||
- **Learning from nudges:** if the mouse took the pointer from the gaze and moved it a little (0.2 to 8 degrees) 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. 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 a little (0.2 to 8 degrees) 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 probe's one-dot check does the same). The helper only sends nudges up to `POINTER_GAZE_NUDGE_MAX` (8 degrees), and right after putting the headset back on our tracker can be further off than that. If so, raise it for a moment, or do the probe's check. 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.
|
||||
- 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.
|
||||
|
||||
Lessons are logged to `pointer-lessons.jsonl`: the raw gaze, the true direction, the correction at the time, and how far off it was.
|
||||
@@ -32,7 +38,7 @@ Lessons are logged to `pointer-lessons.jsonl`: the raw gaze, the true direction,
|
||||
| SteamVR action | An `eyetracking` action bound to `/user/head/eyetracking` (`actions/`), read with `IVRInput::GetEyeTrackingDataRelativeToNow`. This is the supported way. |
|
||||
| mmap set 1, set 2 | `/dev/shm/eye-server.mmap`, which SteamVR's eyetracking process writes for the HMD driver (`driver_cv.so`). It has two sets of per-eye directions in head space: set 1 is filtered, and its two eyes always share one pitch; set 2 is each eye's own reading. After each set come the tracker's variances for each eye, and at the end each eye's raw measurement and its variance (the tracker's confidence in that frame), which ft-gaze passes on for the fit check. |
|
||||
| Left eye, right eye | Each eye alone, from set 2: calibrate and test them to see what one eye is worth against both. The layout is undocumented (offsets are in `ft-gaze.cpp`) and may change with a SteamVR update. ft-gaze maps it read-only; the file also carries calibration clicks to the tracker and must never be written. |
|
||||
| Own tracker | Our own tracker, from `~/Desktop/Projects/frame-eyes` (experimental). `tools/fe-live` runs it: a root helper copies the eye-camera frames out of SteamVR's buffers (read-only) and `fe-trackd` finds the pupils and glints and publishes the gaze in `/dev/shm/frame-eyes-gaze`. It keeps its own calibration, not SteamVR's: the probe's calibration with the tracker toggle on Own tracker fits it (its dots go out to the Calibration ring angle each way, on an oval, since the fit goes wrong past its dots), and practice clicks teach it how far the headset has moved on your face since. After the headset was off, the probe first asks for one look at a centre dot, which resets that. With Own tracker on, the probe hides SteamVR's gaze and draws a red dot where each eye alone puts it. ft-gaze reports it as `own` while it's running, and as `{"ok":0}` otherwise. |
|
||||
| Own tracker | Our own tracker, from `~/Desktop/Projects/frame-eyes` (experimental). `tools/fe-live` runs it: a root helper copies the eye-camera frames out of SteamVR's buffers (read-only) and `fe-trackd` finds the pupils and glints and publishes the gaze in `/dev/shm/frame-eyes-gaze`. It keeps its own calibration, not SteamVR's: the probe's calibration with the tracker toggle on Own tracker fits it (its dots go out to the Calibration ring angle each way, on an oval, since the fit goes wrong past its dots), and practice clicks teach it how far the headset has moved on your face since. After the headset was off, the probe first asks for one look at a centre dot, which resets that. With Own tracker on, the probe hides SteamVR's gaze and draws a red dot where each eye alone puts it. The gaze pointer can use it too (Eye tracker: Own tracker, on the Gaze page of Frametop Input Settings). ft-gaze reports it as `own` while it's running, and as `{"ok":0}` otherwise. |
|
||||
|
||||
The tracker stops when the headset is off your head. SteamVR also calibrates gaze on its own from laser-mouse clicks, treating each click as a spot you were looking at. That includes mouse clicks through the Frametop pointer, so a click where the pointer's dot isn't what you're looking at teaches SteamVR a wrong sample (it only takes clicks within 5 degrees of your gaze). In the probe, use Enter or Space as the trigger: keys don't go through SteamVR's laser. See `Accept usercal` in `~/.local/share/Steam/logs/eyetracking.txt`. When the tracker loses an eye, the same log says `CEyePoseUKF L: Large dt` (or `R`) as it starts that eye over.
|
||||
|
||||
|
||||
+352
-86
@@ -1,34 +1,56 @@
|
||||
#!/usr/bin/python3
|
||||
"""ft-gazed: the gaze service. The headset's eye tracking, corrected, for the pointer.
|
||||
|
||||
Two settings in ~/.config/frametop.conf (the Gaze page of Frametop Input Settings), read
|
||||
again when the file changes:
|
||||
GAZE_TRACKER=steam|own SteamVR's eye tracker (default), or our own (frame-eyes' fe-trackd,
|
||||
ft-gaze's source "own", when it's running)
|
||||
GAZE_EYE=auto|left|right the eye bias (gazecal.EyeWeights): auto weights each eye by how far
|
||||
off it was at your recent nudges; left or right counts that eye twice
|
||||
as much as the other. Either eye alone carries the gaze when the
|
||||
other isn't seen.
|
||||
|
||||
Runs ft-gaze (in the dev container), and for every eye tracker sample (90 Hz):
|
||||
|
||||
1. drops blinks: both eyes' openness under half its running median (each eye its own),
|
||||
or both lost (the tracker's variance for them, ft-gaze's "unc", over EYE_LOST). With
|
||||
one eye lost or closed, the gaze comes from the other (EyeFallback: that eye's own
|
||||
reading from set 2, plus what it usually reads against the combined gaze, learned
|
||||
while both are seen). SteamVR's combined gaze (set 1) keeps going on one eye too, but
|
||||
holds the lost eye's yaw, so it moves half as far sideways as the eyes do. Before the
|
||||
fallback has learned an eye, set 1 is used as it is; set 2's combined direction is
|
||||
the mean of the eyes' own (off by half of whatever the lost eye reads), so with set 2
|
||||
that sample is dropped, as is one where the angle between the eyes jumps more than
|
||||
1.5 degrees from its median;
|
||||
or both lost (the tracker's variance for them, ft-gaze's "unc", over EYE_LOST);
|
||||
Looks down past the screens (pitch under KEYBOARD_PITCH, on no Frametop screen: at the
|
||||
keyboard, through the gap by the nose) aren't sent, so the pointer stays where it was
|
||||
instead of following you down; the tracker often loses an eye there (the lids come
|
||||
down), and that isn't counted as a lost eye either;
|
||||
2. smooths it with a fixation lock (the running mean of the current fixation, 1 degree);
|
||||
3. corrects it: the calibration from ft-gazeprobe (calibration.json, reloaded when the
|
||||
probe changes it) plus what the pointer's corrections have taught since (LiveCorrection,
|
||||
saved in pointer-lessons.json);
|
||||
2. combines the eyes, each corrected on its own. With SteamVR, that's each eye's own
|
||||
reading (set 2, ft-gaze's "left" and "right"), corrected by its calibration from
|
||||
ft-gazeprobe (calibration.json, reloaded when the probe changes it) plus what the
|
||||
pointer's corrections have taught that eye since (LiveCorrection, saved in
|
||||
pointer-lessons.json), then weighted by the eye bias. A lost or closed eye drops out.
|
||||
Our own tracker keeps its own calibration, so its eyes are used as they come;
|
||||
3. smooths it with a fixation lock (the running mean of the current fixation, 1 degree);
|
||||
4. sends it to the pointer helper: "gz <yaw> <pitch> <raw yaw> <raw pitch>", head-relative
|
||||
degrees (yaw +left, pitch +up). The helper uses it only in gaze mode.
|
||||
|
||||
Without per-eye calibrations (a calibration from before the probe had the eyes as sources),
|
||||
or with --source, it's the older path: one source, SteamVR's combined gaze (mmap set 1) by
|
||||
default, corrected as a whole. There, with one eye lost or closed, the gaze comes from the
|
||||
other (EyeFallback: that eye's own reading from set 2, plus what it usually reads against
|
||||
the combined gaze, learned while both are seen). SteamVR's combined gaze (set 1) keeps going
|
||||
on one eye too, but holds the lost eye's yaw, so it moves half as far sideways as the eyes
|
||||
do. Before the fallback has learned an eye, set 1 is used as it is; set 2's combined
|
||||
direction is the mean of the eyes' own (off by half of whatever the lost eye reads), so with
|
||||
set 2 that sample is dropped, as is one where the angle between the eyes jumps more than 1.5
|
||||
degrees from its median.
|
||||
|
||||
Lessons come back from the helper: when you nudge the gaze-placed pointer with the mouse and
|
||||
click, it sends "lesson <raw yaw> <raw pitch> <true yaw> <true pitch>": where the raw gaze
|
||||
was when the mouse took over, and where the pointer was when you clicked (you were looking
|
||||
there). The gap is the tracker's error there, and it's learned, unless it's more than
|
||||
LESSON_MAX degrees past the correction (then it wasn't a nudge onto what you looked at).
|
||||
there). The gap is the tracker's error there. The raw gaze is the one sent, so it also says
|
||||
when that look was (the history of what was sent), and so what each eye read then:
|
||||
- SteamVR: each eye learns its own error, unless the gaze was more than LESSON_MAX degrees
|
||||
past the correction (then it wasn't a nudge onto what you looked at);
|
||||
- our tracker: the look goes to it as a click ("click T YAW PITCH" on @frame_eyes), as the
|
||||
probe's clicks do, and it learns how far the headset has moved on your face. That's
|
||||
what it gets wrong, and after the headset was off, the first click resets it;
|
||||
- either way, how far off each eye was (before the lesson taught it anything) goes to the
|
||||
eye bias, for auto.
|
||||
|
||||
SteamVR's eye tracking log is followed for the headset going on (its eye model starts over,
|
||||
and the error moves): lessons from before count less then, so the first few after it
|
||||
@@ -41,16 +63,18 @@ Control socket: abstract unix datagram "@ft_gazed":
|
||||
lesson <rhy> <rhp> <thy> <thp> from the pointer helper (see above)
|
||||
status reply: one JSON object
|
||||
forget drop what the lessons taught (the calibration stays)
|
||||
reload read calibration.json again
|
||||
reload read calibration.json and the settings again
|
||||
|
||||
Options: --source action|mmap1|mmap2 (default mmap1; set 2 was a little quieter in the probe,
|
||||
but loses the pointer whenever the tracker loses an eye), -v (a status line every
|
||||
5 s on stderr), --to NAME (send the gaze to the abstract socket @NAME instead of the pointer
|
||||
helper; for testing: a helper without gaze mode forwards what it doesn't know to its driver).
|
||||
Options: --source action|mmap1|mmap2 (the older one-source path with that source, whatever
|
||||
the settings say; set 2 was a little quieter in the probe, but loses the pointer whenever
|
||||
the tracker loses an eye), -v (a status line every 5 s on stderr), --to NAME (send the gaze
|
||||
to the abstract socket @NAME instead of the pointer helper; for testing: a helper without
|
||||
gaze mode forwards what it doesn't know to its driver).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import selectors
|
||||
import signal
|
||||
@@ -64,16 +88,25 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from gazecal import (DEFAULT_MODEL, EYE_FOUND, EYE_LOST, MODELS, STATE, Correction, EyeFallback, # noqa: E402
|
||||
Fixation, LiveCorrection, SteamEyeLog)
|
||||
EyeWeights, Fixation, LiveCorrection, SteamEyeLog)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
HELPER = REPO / "gaze" / "build" / "ft-gaze"
|
||||
ME = "\0ft_gazed"
|
||||
POINTER = "\0ft_pointer_helper"
|
||||
FRAME_EYES = "\0frame_eyes" # our own tracker's control socket (frame-eyes fe-trackd)
|
||||
CONF = Path.home() / ".config" / "frametop.conf"
|
||||
CALIBRATION = STATE / "calibration.json"
|
||||
LESSONS = STATE / "pointer-lessons.json"
|
||||
LESSON_LOG = STATE / "pointer-lessons.jsonl"
|
||||
SOURCES = ("action", "mmap1", "mmap2", "left", "right") # the ones with a calibration here
|
||||
EYES = ("left", "right") # ft-gaze's order, and the sources for each eye alone
|
||||
TRACKERS = ("steam", "own")
|
||||
BIASES = ("auto", "left", "right")
|
||||
LESSON_MAX = 8.0 # degrees past the correction
|
||||
OWN_LESSON_MAX = 25.0 # our tracker: after the headset was off, its first clicks can be 10-17 off
|
||||
HISTORY = 12.0 # seconds of the gaze sent, to find a lesson's look (the helper sends it up to 10 s later)
|
||||
LOOK = 0.3 # seconds of samples before that moment make the look (the probe's fixation)
|
||||
RETRY = 3.0 # seconds before starting ft-gaze again
|
||||
SETTLE = 0.3 # seconds after an eye is found again before the fallback learns from it
|
||||
KEYBOARD_PITCH = -20.0 # degrees: gaze under this, on no screen, is a look at the keyboard
|
||||
@@ -95,21 +128,47 @@ def log(msg):
|
||||
print(f"ft-gazed: {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def read_settings():
|
||||
"""(tracker, eye bias) from frametop.conf, defaults for anything missing or unknown."""
|
||||
conf = {}
|
||||
try:
|
||||
for line in CONF.read_text().splitlines():
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
conf[k.strip()] = v.strip().lower()
|
||||
except OSError:
|
||||
pass
|
||||
tracker = conf.get("GAZE_TRACKER", "steam")
|
||||
bias = conf.get("GAZE_EYE", "auto")
|
||||
return tracker if tracker in TRACKERS else "steam", bias if bias in BIASES else "auto"
|
||||
|
||||
|
||||
def mtime(path):
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
class Service:
|
||||
def __init__(self, source, verbose, to=POINTER):
|
||||
self.source, self.verbose, self.to = source, verbose, to
|
||||
self.override, self.verbose, self.to = source, verbose, to
|
||||
self.source = source or "mmap1" # the older path's source
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
self.base = Correction()
|
||||
self.tracker, self.bias = read_settings()
|
||||
self.conf_mtime = mtime(CONF)
|
||||
self.models = {name: Correction() for name in SOURCES}
|
||||
self.mode = DEFAULT_MODEL
|
||||
self.cal_mtime = None
|
||||
self.live = PointerLessons()
|
||||
self.lives = {name: PointerLessons() for name in SOURCES}
|
||||
self.weights = {t: EyeWeights(self.bias) for t in TRACKERS}
|
||||
self.dirty = False
|
||||
self.load_calibration()
|
||||
self.load_lessons()
|
||||
self.steam = SteamEyeLog()
|
||||
self.steam.poll()
|
||||
self.live.wear_time = self.steam.worn()
|
||||
self.live.refit(self.base, self.mode)
|
||||
self.refit()
|
||||
self.fix = Fixation(radius=1.0)
|
||||
self.opens = (deque(maxlen=90), deque(maxlen=90)) # left, right
|
||||
self.vergence = deque(maxlen=90)
|
||||
@@ -120,64 +179,161 @@ class Service:
|
||||
"lost_right": 0, "looking_down": 0, "dropped": 0, "lessons_taken": 0, "refused": 0}
|
||||
self.last_sample = 0.0
|
||||
self.last = None
|
||||
self.last_kind = None
|
||||
# What was sent, for finding a lesson's look: (sample time, raw as sent, each eye's reading).
|
||||
self.history = deque()
|
||||
self.own = {} # our tracker's last status reply
|
||||
self.own_at = 0.0
|
||||
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
|
||||
self.sock.bind(ME)
|
||||
self.out = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
|
||||
# To our tracker, with an address of its own, so its replies don't land on @ft_gazed.
|
||||
self.eyes_sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC | socket.SOCK_NONBLOCK)
|
||||
self.eyes_sock.bind("")
|
||||
self.sel = selectors.DefaultSelector()
|
||||
self.sel.register(self.sock, selectors.EVENT_READ, "control")
|
||||
self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own")
|
||||
self.proc = None
|
||||
self.buf = b""
|
||||
self.restart_at = 0.0
|
||||
self.running = True
|
||||
|
||||
# --- Calibration and lessons ---
|
||||
@property
|
||||
def kind(self):
|
||||
""""own" (our tracker), "eyes" (SteamVR's eyes, each corrected), or "source" (the
|
||||
older path: one SteamVR source, corrected as a whole)."""
|
||||
if self.override:
|
||||
return "source"
|
||||
if self.tracker == "own":
|
||||
return "own"
|
||||
return "eyes" if all(self.models[e].samples for e in EYES) else "source"
|
||||
|
||||
# --- Settings, calibration and lessons ---
|
||||
|
||||
def load_settings(self):
|
||||
self.conf_mtime = mtime(CONF)
|
||||
tracker, bias = read_settings()
|
||||
if (tracker, bias) != (self.tracker, self.bias):
|
||||
log(f"tracker {tracker}, eye bias {bias}" + (f" (--source {self.override} wins)" if self.override else ""))
|
||||
self.tracker, self.bias = tracker, bias
|
||||
for w in self.weights.values():
|
||||
w.bias = bias
|
||||
self.fix.reset()
|
||||
|
||||
def load_calibration(self):
|
||||
try:
|
||||
mtime = CALIBRATION.stat().st_mtime
|
||||
mt = CALIBRATION.stat().st_mtime
|
||||
d = json.loads(CALIBRATION.read_text())
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
self.cal_mtime = mtime
|
||||
if self.source in d:
|
||||
self.base.from_json(d[self.source])
|
||||
self.cal_mtime = mt
|
||||
for name in SOURCES:
|
||||
if name in d:
|
||||
self.models[name].from_json(d[name])
|
||||
mode = d.get("_meta", {}).get("model")
|
||||
self.mode = mode if mode in MODELS else DEFAULT_MODEL
|
||||
log(f"calibration: {self.mode}, {self.base.samples} samples")
|
||||
log(f"calibration: {self.mode}, " + ", ".join(f"{n} {self.models[n].samples}" for n in (self.source,) + EYES)
|
||||
+ " samples")
|
||||
|
||||
def load_lessons(self):
|
||||
try:
|
||||
d = json.loads(LESSONS.read_text())
|
||||
if d.get("source") == self.source:
|
||||
self.live.samples = d.get("samples", [])[-PointerLessons.KEEP:]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
log(f"{len(self.live.samples)} lessons")
|
||||
d = {}
|
||||
# The first version kept one source's: {"source": NAME, "samples": [...]}.
|
||||
sources = d.get("sources") or ({d["source"]: d.get("samples", [])} if "source" in d else {})
|
||||
for name, samples in sources.items():
|
||||
if name in self.lives:
|
||||
self.lives[name].samples = samples[-PointerLessons.KEEP:]
|
||||
for t, misses in (d.get("misses") or {}).items():
|
||||
if t in self.weights:
|
||||
self.weights[t] = EyeWeights(self.bias, misses)
|
||||
log(", ".join(f"{n} {len(self.lives[n].samples)}" for n in (self.source,) + EYES) + " lessons")
|
||||
|
||||
def save_lessons(self):
|
||||
tmp = LESSONS.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"source": self.source, "samples": self.live.samples}))
|
||||
tmp.write_text(json.dumps({"version": 2, "sources": {n: lv.samples for n, lv in self.lives.items() if lv.samples},
|
||||
"misses": {t: w.misses for t, w in self.weights.items()}}))
|
||||
tmp.replace(LESSONS)
|
||||
self.dirty = False
|
||||
|
||||
def correction(self, hy, hp):
|
||||
by, bp = self.base.get(hy, hp, self.mode)
|
||||
ly, lp = self.live.get(hy, hp)
|
||||
def refit(self):
|
||||
for name, live in self.lives.items():
|
||||
live.wear_time = self.steam.worn()
|
||||
live.refit(self.models[name], self.mode)
|
||||
|
||||
def correction(self, name, hy, hp):
|
||||
by, bp = self.models[name].get(hy, hp, self.mode)
|
||||
ly, lp = self.lives[name].get(hy, hp)
|
||||
return by + ly, bp + lp
|
||||
|
||||
def look(self, ry, rp):
|
||||
"""When the gaze sent as raw (ry, rp) was last sent, and each eye's median reading over
|
||||
the LOOK before it: (t, [(yaw, pitch) or None] * 2), or (None, None)."""
|
||||
key = f"{ry:.3f} {rp:.3f}"
|
||||
t = next((h[0] for h in reversed(self.history) if h[1] == key), None)
|
||||
if t is None:
|
||||
return None, None
|
||||
eyes = []
|
||||
for k in (0, 1):
|
||||
seen = [h[2][k] for h in self.history if t - LOOK <= h[0] <= t and h[2] and h[2][k]]
|
||||
eyes.append((statistics.median(e[0] for e in seen), statistics.median(e[1] for e in seen)) if seen else None)
|
||||
return t, eyes
|
||||
|
||||
def lesson(self, rhy, rhp, thy, thp):
|
||||
dy, dp = thy - rhy, thp - rhp # the whole error there
|
||||
cy, cp = self.correction(rhy, rhp)
|
||||
left = ((dy - cy) ** 2 + (dp - cp) ** 2) ** 0.5
|
||||
rec = {"time": time.time(), "source": self.source, "model": self.mode, "raw": [rhy, rhp],
|
||||
"true": [thy, thp], "correction": [cy, cp], "lesson_deg": left, "wear": self.steam.worn()}
|
||||
if left > LESSON_MAX:
|
||||
rec["refused"] = f"more than {LESSON_MAX} deg past the correction"
|
||||
kind = self.kind
|
||||
rec = {"time": time.time(), "kind": kind, "raw": [rhy, rhp], "true": [thy, thp], "wear": self.steam.worn()}
|
||||
if kind == "source":
|
||||
dy, dp = thy - rhy, thp - rhp # the whole error there
|
||||
cy, cp = self.correction(self.source, rhy, rhp)
|
||||
left = math.hypot(dy - cy, dp - cp)
|
||||
rec.update(source=self.source, model=self.mode, correction=[cy, cp], lesson_deg=left)
|
||||
if left > LESSON_MAX:
|
||||
rec["refused"] = f"more than {LESSON_MAX} deg past the correction"
|
||||
else:
|
||||
self.lives[self.source].add({"time": rec["time"], "hy": rhy, "hp": rhp, "dy": dy, "dp": dp,
|
||||
"wy": 1.0, "wp": 1.0, "how": "pointer"}, self.models[self.source], self.mode)
|
||||
return self.taken(rec)
|
||||
# The raw gaze sent here is the corrected, combined one: its whole error is left.
|
||||
left = math.hypot(thy - rhy, thp - rhp)
|
||||
t, eyes = self.look(rhy, rhp)
|
||||
weights = self.weights[self.tracker if kind == "own" else "steam"]
|
||||
rec.update(tracker=self.tracker if kind == "own" else "steam", bias=self.bias, lesson_deg=left, look_t=t,
|
||||
eyes=eyes, weights=[round(w, 3) for w in weights.weights()])
|
||||
limit = OWN_LESSON_MAX if kind == "own" else LESSON_MAX
|
||||
if t is None:
|
||||
rec["refused"] = "that gaze isn't in the last few seconds sent"
|
||||
elif left > limit:
|
||||
rec["refused"] = f"more than {limit} deg off"
|
||||
if "refused" in rec:
|
||||
return self.taken(rec)
|
||||
if kind == "own":
|
||||
# Its eyes come calibrated: how far off each was is its miss. The click goes to it.
|
||||
miss = [math.hypot(thy - e[0], thp - e[1]) if e else None for e in eyes]
|
||||
try:
|
||||
self.eyes_sock.sendto(f"click {t:.6f} {thy:.4f} {thp:.4f}".encode(), FRAME_EYES)
|
||||
except OSError as e:
|
||||
rec["refused"] = f"our tracker isn't running ({e})"
|
||||
return self.taken(rec)
|
||||
else:
|
||||
miss = []
|
||||
for name, e in zip(EYES, eyes):
|
||||
if not e:
|
||||
miss.append(None)
|
||||
continue
|
||||
cy, cp = self.correction(name, *e)
|
||||
miss.append(math.hypot(thy - e[0] - cy, thp - e[1] - cp))
|
||||
self.lives[name].add({"time": rec["time"], "hy": e[0], "hp": e[1], "dy": thy - e[0], "dp": thp - e[1],
|
||||
"wy": 1.0, "wp": 1.0, "how": "pointer"}, self.models[name], self.mode)
|
||||
rec["miss"] = miss
|
||||
weights.add(miss)
|
||||
return self.taken(rec)
|
||||
|
||||
def taken(self, rec):
|
||||
if "refused" in rec:
|
||||
self.counts["refused"] += 1
|
||||
else:
|
||||
self.live.add({"time": rec["time"], "hy": rhy, "hp": rhp, "dy": dy, "dp": dp, "wy": 1.0, "wp": 1.0,
|
||||
"how": "pointer"}, self.base, self.mode)
|
||||
self.counts["lessons_taken"] += 1
|
||||
self.dirty = True
|
||||
try:
|
||||
@@ -255,19 +411,13 @@ class Service:
|
||||
if line.strip():
|
||||
log(line)
|
||||
|
||||
def on_sample(self, s):
|
||||
src = s["src"].get(self.source) or {}
|
||||
if "hy" not in src:
|
||||
return
|
||||
self.counts["samples"] += 1
|
||||
self.last_sample = time.monotonic()
|
||||
m1 = s["src"].get("mmap1") or {}
|
||||
o = m1.get("open")
|
||||
lr = src.get("lr", m1.get("lr"))
|
||||
# Blinks and lost eyes, judged against the last second (see steady_samples: relative,
|
||||
# because the lids come down looking down, and the vergence depends on distance).
|
||||
# An eye's floor comes from its good readings, so a lost eye doesn't drag it to 0.
|
||||
def judge_eyes(self, m1, down):
|
||||
"""Which eyes (left, right) are closed, from SteamVR's openness (set 1); updates
|
||||
self.lost from its variances. Blinks and lost eyes are judged against the last second
|
||||
(see steady_samples: relative, because the lids come down looking down). An eye's
|
||||
floor comes from its good readings, so a lost eye doesn't drag it to 0."""
|
||||
low = [False, False]
|
||||
o = m1.get("open")
|
||||
if o and len(o) == 2:
|
||||
for k in (0, 1):
|
||||
hist = self.opens[k]
|
||||
@@ -276,13 +426,78 @@ class Service:
|
||||
low[k] = o[k] < floor
|
||||
hist.append(o[k])
|
||||
unc = m1.get("unc")
|
||||
down = src["hp"] < KEYBOARD_PITCH and not src.get("hit")
|
||||
if unc and len(unc) == 2:
|
||||
for k in (0, 1):
|
||||
self.lost[k] = unc[k] > (EYE_FOUND if self.lost[k] else EYE_LOST)
|
||||
if not down:
|
||||
self.counts["lost_left"] += self.lost[0]
|
||||
self.counts["lost_right"] += self.lost[1]
|
||||
return low
|
||||
|
||||
def on_sample(self, s):
|
||||
kind = self.kind
|
||||
if kind != self.last_kind:
|
||||
log({"own": "our own tracker", "eyes": "SteamVR's eyes, each calibrated",
|
||||
"source": f"SteamVR's {self.source}, calibrated as a whole"}[kind]
|
||||
+ (f", eye bias {self.bias}" if kind != "source" else ""))
|
||||
self.last_kind = kind
|
||||
self.fix.reset()
|
||||
if kind == "source":
|
||||
self.on_source_sample(s)
|
||||
else:
|
||||
self.on_eyes_sample(s, kind == "own")
|
||||
|
||||
def on_eyes_sample(self, s, own):
|
||||
m1 = s["src"].get("mmap1") or {}
|
||||
if own:
|
||||
src = s["src"].get("own") or {}
|
||||
if "hy" not in src:
|
||||
return
|
||||
eyes = [tuple(e) if e else None for e in (src.get("eyes") or [None, None])]
|
||||
hp, hit = src["hp"], src.get("hit")
|
||||
else:
|
||||
per = [s["src"].get(name) or {} for name in EYES]
|
||||
eyes = [(p["hy"], p["hp"]) if "hy" in p else None for p in per]
|
||||
if not any(eyes):
|
||||
return
|
||||
hp, hit = next(e[1] for e in eyes if e), m1.get("hit")
|
||||
self.counts["samples"] += 1
|
||||
self.last_sample = time.monotonic()
|
||||
down = hp < KEYBOARD_PITCH and not hit
|
||||
low = self.judge_eyes(m1, down)
|
||||
if down:
|
||||
self.counts["looking_down"] += 1
|
||||
return
|
||||
# Our tracker finds the pupils itself; SteamVR's openness still marks the blinks.
|
||||
bad = [eyes[k] is None or low[k] or (not own and self.lost[k]) for k in (0, 1)]
|
||||
if all(bad):
|
||||
self.counts["blinks"] += 1
|
||||
return
|
||||
if any(bad):
|
||||
self.counts["one_eye"] += 1
|
||||
self.counts["one_eye_used"] += 1
|
||||
seen = [None if bad[k] else eyes[k] for k in (0, 1)]
|
||||
if own:
|
||||
corrected = seen
|
||||
else:
|
||||
corrected = []
|
||||
for name, e in zip(EYES, seen):
|
||||
c = self.correction(name, *e) if e else None
|
||||
corrected.append((e[0] + c[0], e[1] + c[1]) if e else None)
|
||||
gy, gp = self.weights["own" if own else "steam"].combine(corrected)
|
||||
fy, fp = self.fix(gy, gp, s["t"], 1.0)
|
||||
self.send(s["t"], fy, fp, fy, fp, seen)
|
||||
|
||||
def on_source_sample(self, s):
|
||||
src = s["src"].get(self.source) or {}
|
||||
if "hy" not in src:
|
||||
return
|
||||
self.counts["samples"] += 1
|
||||
self.last_sample = time.monotonic()
|
||||
m1 = s["src"].get("mmap1") or {}
|
||||
lr = src.get("lr", m1.get("lr"))
|
||||
down = src["hp"] < KEYBOARD_PITCH and not src.get("hit")
|
||||
low = self.judge_eyes(m1, down)
|
||||
if down:
|
||||
self.counts["looking_down"] += 1
|
||||
for k in (0, 1):
|
||||
@@ -323,10 +538,17 @@ class Service:
|
||||
self.fallback.update(k, eyes[k][0], eyes[k][1], hy, hp)
|
||||
# The fixation lock works in degrees here (1 degree per "pixel").
|
||||
fy, fp = self.fix(hy, hp, s["t"], 1.0)
|
||||
cy, cp = self.correction(fy, fp)
|
||||
self.last = (fy + cy, fp + cp, fy, fp)
|
||||
cy, cp = self.correction(self.source, fy, fp)
|
||||
self.send(s["t"], fy + cy, fp + cp, fy, fp, None)
|
||||
|
||||
def send(self, t, hy, hp, rhy, rhp, eyes):
|
||||
self.last = (hy, hp, rhy, rhp)
|
||||
raw = f"{rhy:.3f} {rhp:.3f}"
|
||||
self.history.append((t, raw, eyes))
|
||||
while self.history and self.history[0][0] < t - HISTORY:
|
||||
self.history.popleft()
|
||||
try:
|
||||
self.out.sendto(f"gz {fy + cy:.3f} {fp + cp:.3f} {fy:.3f} {fp:.3f}".encode(), self.to)
|
||||
self.out.sendto(f"gz {hy:.3f} {hp:.3f} {raw}".encode(), self.to)
|
||||
self.counts["sent"] += 1
|
||||
except OSError:
|
||||
pass # the pointer helper isn't running
|
||||
@@ -346,19 +568,23 @@ class Service:
|
||||
rec = self.lesson(*map(float, words[1:]))
|
||||
reply = "refused" if "refused" in rec else f"ok {rec['lesson_deg']:.2f}"
|
||||
log(f"lesson {rec['lesson_deg']:.2f} deg at {rec['raw'][0]:+.1f},{rec['raw'][1]:+.1f}"
|
||||
+ (f", eyes off {', '.join('-' if m is None else f'{m:.2f}' for m in rec['miss'])}"
|
||||
if rec.get("miss") else "")
|
||||
+ (f": {rec['refused']}" if "refused" in rec else ""))
|
||||
except ValueError:
|
||||
reply = "error bad lesson"
|
||||
elif words[:1] == ["status"]:
|
||||
reply = json.dumps(self.status())
|
||||
elif words[:1] == ["forget"]:
|
||||
self.live = PointerLessons()
|
||||
self.live.wear_time = self.steam.worn()
|
||||
self.lives = {name: PointerLessons() for name in SOURCES}
|
||||
self.weights = {t: EyeWeights(self.bias) for t in TRACKERS}
|
||||
self.refit()
|
||||
self.save_lessons()
|
||||
reply = "ok"
|
||||
elif words[:1] == ["reload"]:
|
||||
self.load_settings()
|
||||
self.load_calibration()
|
||||
self.live.refit(self.base, self.mode)
|
||||
self.refit()
|
||||
reply = "ok"
|
||||
else:
|
||||
reply = "error unknown command"
|
||||
@@ -368,28 +594,65 @@ class Service:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def on_own(self):
|
||||
"""Replies from our tracker: its status (JSON), or a click's "ok ..."/"fail ..."."""
|
||||
while True:
|
||||
try:
|
||||
data = self.eyes_sock.recv(4096).decode("utf-8", "replace")
|
||||
except (BlockingIOError, OSError):
|
||||
return
|
||||
if data.startswith("{"):
|
||||
try:
|
||||
self.own, self.own_at = json.loads(data), time.monotonic()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
log(f"our tracker: {data}")
|
||||
|
||||
def status(self):
|
||||
ly, lp = self.live.offset()
|
||||
return {"source": self.source, "model": self.mode, "calibration_samples": self.base.samples,
|
||||
"lessons": len(self.live.samples), "lesson_offset": [round(ly, 3), round(lp, 3)],
|
||||
"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2)
|
||||
if self.last_sample else None, "headset_on": self.steam.wearing(), "headset_on_since": self.steam.worn(),
|
||||
"last": [round(v, 2) for v in self.last] if self.last else None,
|
||||
"eyes_lost": self.lost, "fallback_ready": [self.fallback.ready(0), self.fallback.ready(1)], **self.counts}
|
||||
kind = self.kind
|
||||
tracker = "own" if kind == "own" else "steam"
|
||||
w = self.weights[tracker]
|
||||
st = {"tracker": tracker, "kind": kind, "source": "own" if kind == "own" else self.source if kind == "source"
|
||||
else "left+right", "model": self.mode, "eye_bias": self.bias}
|
||||
if kind == "source":
|
||||
ly, lp = self.lives[self.source].offset()
|
||||
st.update(calibration_samples=self.models[self.source].samples, lessons=len(self.lives[self.source].samples),
|
||||
lesson_offset=[round(ly, 3), round(lp, 3)])
|
||||
else:
|
||||
st.update(eye_weights=[round(v, 3) for v in w.weights()], eye_misses=[len(m) for m in w.misses],
|
||||
eye_rms=[None if r is None else round(r, 2) for r in w.rms()])
|
||||
if kind == "eyes":
|
||||
st.update(calibration_samples=min(self.models[e].samples for e in EYES),
|
||||
lessons=max(len(self.lives[e].samples) for e in EYES))
|
||||
if kind == "own":
|
||||
own = self.own if time.monotonic() - self.own_at < 5 else {}
|
||||
cal = own.get("calibration") or {}
|
||||
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),
|
||||
own_reseat=any(e.get("reseat") for e in own.get("eyes", {}).values()))
|
||||
st.update({"ft_gaze": self.proc is not None, "sample_age_s": round(time.monotonic() - self.last_sample, 2)
|
||||
if self.last_sample else None, "headset_on": self.steam.wearing(),
|
||||
"headset_on_since": self.steam.worn(), "last": [round(v, 2) for v in self.last] if self.last else None,
|
||||
"eyes_lost": self.lost, "fallback_ready": [self.fallback.ready(0), self.fallback.ready(1)],
|
||||
**self.counts})
|
||||
return st
|
||||
|
||||
def periodic(self):
|
||||
if self.steam.poll() or self.steam.worn() != self.live.wear_time:
|
||||
if self.steam.worn() != self.live.wear_time:
|
||||
if self.steam.poll() or any(lv.wear_time != self.steam.worn() for lv in self.lives.values()):
|
||||
if any(lv.wear_time != self.steam.worn() for lv in self.lives.values()):
|
||||
log("headset on again: older lessons count less until new ones come in")
|
||||
self.live.wear_time = self.steam.worn()
|
||||
self.live.refit(self.base, self.mode)
|
||||
try:
|
||||
mtime = CALIBRATION.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = None
|
||||
if mtime != self.cal_mtime:
|
||||
self.refit()
|
||||
if mtime(CALIBRATION) != self.cal_mtime:
|
||||
self.load_calibration()
|
||||
self.live.refit(self.base, self.mode)
|
||||
self.refit()
|
||||
if mtime(CONF) != self.conf_mtime:
|
||||
self.load_settings()
|
||||
if self.tracker == "own":
|
||||
try:
|
||||
self.eyes_sock.sendto(b"status", FRAME_EYES)
|
||||
except OSError:
|
||||
pass # not running: status() says so once the last answer is old
|
||||
if self.dirty:
|
||||
self.save_lessons()
|
||||
|
||||
@@ -403,6 +666,8 @@ class Service:
|
||||
for key, _ in self.sel.select(timeout=0.5):
|
||||
if key.data == "control":
|
||||
self.on_control()
|
||||
elif key.data == "own":
|
||||
self.on_own()
|
||||
elif key.data == "stdout" and self.proc:
|
||||
self.read_stdout()
|
||||
elif key.data == "stderr" and self.proc:
|
||||
@@ -420,7 +685,8 @@ class Service:
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="The gaze service: corrected eye tracking for the pointer")
|
||||
ap.add_argument("--source", choices=["action", "mmap1", "mmap2"], default="mmap1")
|
||||
ap.add_argument("--source", choices=["action", "mmap1", "mmap2"],
|
||||
help="the older one-source path with this SteamVR source, whatever the settings say")
|
||||
ap.add_argument("-v", "--verbose", action="store_true")
|
||||
ap.add_argument("--to", default="ft_pointer_helper", help="abstract socket to send the gaze to")
|
||||
args = ap.parse_args()
|
||||
|
||||
+57
-1
@@ -3,7 +3,7 @@
|
||||
The correction models (Correction: the calibration fitted from calibration dots;
|
||||
LiveCorrection: what clicks teach on the fly, on top of it), the smoothing filters, the
|
||||
blink and dropout filter for one look at a spot, EyeFallback (the gaze from one eye while
|
||||
the tracker has lost the other), and SteamEyeLog, which follows SteamVR's eye tracking log. Angles are head-relative degrees (yaw +left, pitch +up), as ft-gaze
|
||||
the tracker has lost the other), EyeWeights (how much each eye counts), and SteamEyeLog, which follows SteamVR's eye tracking log. Angles are head-relative degrees (yaw +left, pitch +up), as ft-gaze
|
||||
reports them.
|
||||
"""
|
||||
|
||||
@@ -475,6 +475,62 @@ class EyeFallback:
|
||||
return ey + oy, ep + op
|
||||
|
||||
|
||||
class EyeWeights:
|
||||
"""How much each eye (0 left, 1 right) counts in the gaze, for ft-gazed's eye bias.
|
||||
|
||||
Two eyes beat either one: their errors partly cancel. On 306 live clicks with our own
|
||||
tracker (frame-eyes, 2026-09-29) the eyes' sideways errors were correlated -0.37, and
|
||||
the mean of both was 0.65 degrees off (median), the left eye alone 0.96, the right 1.11.
|
||||
So a bias leans instead of choosing: "left" or "right" counts that eye LEAN times the
|
||||
other (on those clicks, 2:1 toward the better eye cost about 0.03 degrees, toward the
|
||||
worse one about 0.13). "auto" weights each
|
||||
by the inverse square of its RMS miss at the last KEEP lessons, once both have MIN, and
|
||||
alike until then. Each miss is measured before its lesson teaches anything, so each is a
|
||||
fresh test. On SteamVR's own test (2026-09-29) its calibration dots said the left eye was
|
||||
the better one and new spots said the right, so the misses come from lessons, not the fit.
|
||||
An eye that isn't seen (None) drops out, and the other carries the gaze alone."""
|
||||
|
||||
LEAN = 2.0
|
||||
KEEP = 20
|
||||
MIN = 5
|
||||
FLOOR = 0.3 # degrees: so one lucky run can't give an eye all the weight
|
||||
STALE = 8.0 # degrees: a miss this big is the headset moved, not the eye's accuracy
|
||||
|
||||
def __init__(self, bias="auto", misses=None):
|
||||
self.bias = bias
|
||||
self.misses = [list(m) for m in (misses or ([], []))]
|
||||
|
||||
def add(self, miss):
|
||||
"""One lesson's miss per eye (degrees, None where it wasn't seen)."""
|
||||
if any(m is not None and m > self.STALE for m in miss):
|
||||
return
|
||||
for k, m in enumerate(miss):
|
||||
if m is not None:
|
||||
self.misses[k] = (self.misses[k] + [m])[-self.KEEP:]
|
||||
|
||||
def rms(self):
|
||||
return [math.sqrt(sum(m * m for m in ms) / len(ms)) if ms else None for ms in self.misses]
|
||||
|
||||
def weights(self):
|
||||
"""(left, right), summing to 1."""
|
||||
if self.bias in ("left", "right"):
|
||||
w = [self.LEAN, 1.0] if self.bias == "left" else [1.0, self.LEAN]
|
||||
elif all(len(ms) >= self.MIN for ms in self.misses):
|
||||
w = [1.0 / max(r, self.FLOOR) ** 2 for r in self.rms()]
|
||||
else:
|
||||
w = [1.0, 1.0]
|
||||
return w[0] / sum(w), w[1] / sum(w)
|
||||
|
||||
def combine(self, eyes):
|
||||
"""The weighted gaze from [(yaw, pitch) or None, (yaw, pitch) or None], or None."""
|
||||
w = [wk for wk, e in zip(self.weights(), eyes) if e is not None]
|
||||
seen = [e for e in eyes if e is not None]
|
||||
if not seen:
|
||||
return None
|
||||
total = sum(w)
|
||||
return (sum(wk * e[0] for wk, e in zip(w, seen)) / total, sum(wk * e[1] for wk, e in zip(w, seen)) / total)
|
||||
|
||||
|
||||
class SteamEyeLog:
|
||||
"""Follows SteamVR's eye tracking log (read only) for what moves the raw gaze under a
|
||||
calibration.
|
||||
|
||||
@@ -13,7 +13,8 @@ to the input relay over its control socket (@frametop_relay):
|
||||
- Ignored panels: SteamVR overlays the pointer passes through (POINTER_IGNORE), by app or
|
||||
one by one. The helper lists them (@ft_pointer_helper "overlays").
|
||||
- Gaze: the pointer's gaze mode (@ft_pointer_helper "gaze") and the gaze service
|
||||
(gaze/ft-gazed, @ft_gazed: status, forget, reload).
|
||||
(gaze/ft-gazed, @ft_gazed: status, forget, reload), with its eye tracker (SteamVR's or
|
||||
our own) and eye bias (GAZE_TRACKER, GAZE_EYE in frametop.conf).
|
||||
- Bluetooth: paired devices, and re-applying the Bluetooth LE fixes after pairing.
|
||||
- A warning on every page when SteamVR won't load the ft_pointer driver (blocked after a
|
||||
crash, disabled, or SteamVR in safe mode) or hasn't loaded it (@ft_pointer doesn't answer
|
||||
@@ -83,6 +84,9 @@ GAZE_SETTINGS = [
|
||||
("POINTER_GAZE_HOLD", "Hold still to drag", 0.5, 0.1, 2.0, 0.05, "s"),
|
||||
("POINTER_GAZE_SHOW", "Dot shows after moving", 1.0, 0.0, 5.0, 0.1, "s"),
|
||||
]
|
||||
# The gaze service's settings (gaze/ft-gazed): whose eye tracking, and the eye bias.
|
||||
GAZE_TRACKERS = {"steam": "SteamVR's eye tracker", "own": "our own eye tracker (frame-eyes)"}
|
||||
GAZE_EYES = {"auto": "auto", "left": "left eye", "right": "right eye"}
|
||||
# Pointer settings: key, label, default, min, max, step, unit.
|
||||
POINTER_SETTINGS = [
|
||||
("POINTER_SENSITIVITY", "Speed", 0.03, 0.005, 0.12, 0.001, "°/count"),
|
||||
@@ -721,6 +725,36 @@ class Backend(QObject):
|
||||
self.reload_timer.start()
|
||||
self.gazeChanged.emit()
|
||||
|
||||
@Property(str, notify=gazeChanged)
|
||||
def gazeTracker(self):
|
||||
"""Whose eye tracking the gaze service uses: "steam" or "own" (GAZE_TRACKER)."""
|
||||
v = read_conf().get("GAZE_TRACKER", "steam")
|
||||
return v if v in GAZE_TRACKERS else "steam"
|
||||
|
||||
@Property(str, notify=gazeChanged)
|
||||
def gazeEye(self):
|
||||
"""The eye bias: "auto", "left" or "right" (GAZE_EYE)."""
|
||||
v = read_conf().get("GAZE_EYE", "auto")
|
||||
return v if v in GAZE_EYES else "auto"
|
||||
|
||||
@Slot(str)
|
||||
def setGazeTracker(self, tracker):
|
||||
if tracker in GAZE_TRACKERS:
|
||||
self._set_gaze("GAZE_TRACKER", tracker, f"Gaze from {GAZE_TRACKERS[tracker]}")
|
||||
|
||||
@Slot(str)
|
||||
def setGazeEye(self, eye):
|
||||
if eye in GAZE_EYES:
|
||||
self._set_gaze("GAZE_EYE", eye, f"Eye bias: {GAZE_EYES[eye]}")
|
||||
|
||||
def _set_gaze(self, key, value, done):
|
||||
"""ft-gazed reads these from frametop.conf; "reload" makes it do so now."""
|
||||
write_conf_value(key, value)
|
||||
running = self._send("reload", GAZED)
|
||||
self.message.emit(done if running else f"{done} (the gaze service isn't running: it takes it when it starts)",
|
||||
False)
|
||||
self.gazeChanged.emit()
|
||||
|
||||
@Property("QVariantList", notify=pointerChanged)
|
||||
def gazeSettings(self):
|
||||
conf = read_conf()
|
||||
|
||||
+62
-2
@@ -771,6 +771,61 @@ Kirigami.ApplicationWindow {
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Eye tracker:"
|
||||
Controls.RadioButton {
|
||||
text: "SteamVR"
|
||||
checked: backend.gazeTracker === "steam"
|
||||
onToggled: if (checked) backend.setGazeTracker("steam")
|
||||
}
|
||||
Controls.RadioButton {
|
||||
text: "Own tracker (frame-eyes)"
|
||||
checked: backend.gazeTracker === "own"
|
||||
onToggled: if (checked) backend.setGazeTracker("own")
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
// Ours runs apart from Frametop (frame-eyes: tools/fe-live) and keeps its own calibration.
|
||||
visible: backend.gazeTracker === "own" && backend.gazeServiceRunning
|
||||
&& (!gpage.status.own_running || gpage.status.own_reseat || !gpage.status.calibration_samples)
|
||||
text: !gpage.status.own_running ? "Not running: start it in frame-eyes (tools/fe-live), then calibrate"
|
||||
: !gpage.status.calibration_samples ? "Not calibrated: use Calibrate… with Own tracker"
|
||||
: "The headset was off: your first nudge and click resets where it sits"
|
||||
color: gpage.status.own_running ? Kirigami.Theme.neutralTextColor : Kirigami.Theme.negativeTextColor
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
RowLayout {
|
||||
Kirigami.FormData.label: "Eye bias:"
|
||||
Controls.RadioButton {
|
||||
text: "Auto"
|
||||
checked: backend.gazeEye === "auto"
|
||||
onToggled: if (checked) backend.setGazeEye("auto")
|
||||
}
|
||||
Controls.RadioButton {
|
||||
text: "Left"
|
||||
checked: backend.gazeEye === "left"
|
||||
onToggled: if (checked) backend.setGazeEye("left")
|
||||
}
|
||||
Controls.RadioButton {
|
||||
text: "Right"
|
||||
checked: backend.gazeEye === "right"
|
||||
onToggled: if (checked) backend.setGazeEye("right")
|
||||
}
|
||||
}
|
||||
Controls.Label {
|
||||
// What the bias comes to now; on auto, from how far off each eye was at the last nudges.
|
||||
property var w: gpage.status.eye_weights
|
||||
property var n: gpage.status.eye_misses || [0, 0]
|
||||
property var rms: gpage.status.eye_rms || [null, null]
|
||||
visible: backend.gazeServiceRunning && w !== undefined
|
||||
text: w === undefined ? "" : "Left " + Math.round(w[0] * 100) + "% · right " + Math.round(w[1] * 100) + "%"
|
||||
+ (backend.gazeEye !== "auto" ? ""
|
||||
: n[0] >= 5 && n[1] >= 5
|
||||
? " (off by " + rms[0].toFixed(1) + "° and " + rms[1].toFixed(1) + "° at your last nudges)"
|
||||
: " (even until each eye has 5 nudges: " + n[0] + " and " + n[1] + " so far)")
|
||||
opacity: 0.7
|
||||
font: Kirigami.Theme.smallFont
|
||||
}
|
||||
Repeater {
|
||||
model: backend.gazeSettings
|
||||
delegate: RowLayout {
|
||||
@@ -843,7 +898,9 @@ Kirigami.ApplicationWindow {
|
||||
visible: backend.gazeServiceRunning
|
||||
Kirigami.FormData.label: "Calibration:"
|
||||
text: gpage.status.calibration_samples > 0
|
||||
? gpage.status.calibration_samples + " points (" + gpage.status.model + ")"
|
||||
? gpage.status.calibration_samples + " points ("
|
||||
+ (gpage.status.tracker === "own" ? "Own tracker, " + gpage.status.calibration_made
|
||||
: gpage.status.kind === "eyes" ? gpage.status.model + ", each eye" : gpage.status.model) + ")"
|
||||
: "none yet: use Calibrate…"
|
||||
}
|
||||
Controls.Label {
|
||||
@@ -863,7 +920,10 @@ Kirigami.ApplicationWindow {
|
||||
+ "with the button held and let go there. Hold still to drag: hold a press this long without moving "
|
||||
+ "to drag something instead. Look away to hand back: how far from the pointer you look before the "
|
||||
+ "gaze takes it back from the mouse. Largest nudge to learn: bigger mouse moves before a click "
|
||||
+ "are treated as using the mouse, not correcting the gaze."
|
||||
+ "are treated as using the mouse, not correcting the gaze. Eye tracker: SteamVR's, or our own "
|
||||
+ "(frame-eyes), which keeps its own calibration (Calibrate… with Own tracker). Eye bias: the gaze "
|
||||
+ "combines both eyes, since their errors partly cancel; Left or Right counts that eye twice as "
|
||||
+ "much, and Auto weights each by how far off it was at your recent nudges."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ POINTER_GAZE_RETAKE=5 # gaze mode: how far (degrees) you look away from the
|
||||
POINTER_GAZE_NUDGE_MAX=8 # gaze mode: a mouse nudge up to this far (degrees) before a click is learned as the eye tracker's error
|
||||
POINTER_GAZE_HOLD=0.5 # gaze mode: a press held this long (s) without moving becomes a real press (to drag); moved or released sooner, it clicks where you let go
|
||||
POINTER_GAZE_SHOW=1 # gaze mode: the dot shows this long (s) after the mouse moves it; also while a press is held, and a pulse per click
|
||||
GAZE_TRACKER=steam # gaze service: steam = SteamVR's eye tracker | own = our own (frame-eyes' fe-trackd, calibrated in the gaze probe with Own tracker)
|
||||
GAZE_EYE=auto # gaze service: eye bias. auto = each eye weighted by how far off it was at your recent nudges | left | right = that eye counts twice
|
||||
META_DASHBOARD=0 # 1 = a Meta tap on a pass-through keyboard toggles the SteamVR dashboard (pointer mode only)
|
||||
SHARE_KEYS=0 # 1 = keys of keyboards grabbed for the desktop also go to @frametop_keys, for hotkey tools (any local process can listen)
|
||||
DISPLAY_OFF_MIN=0 # minutes without use before frametop-power turns the displays off, even if the headset seems worn (0 = never)
|
||||
|
||||
Reference in new issue
Block a user