Skip to content

fix(eyegaze): hold zoom on gaze loss (crosshair park), surface degraded tracker mode, merge GazeFilter - #74

Merged
willwade merged 5 commits into
mainfrom
fix/eyegaze-loss-pause-and-filter
Oct 30, 2026
Merged

willwade merged 5 commits into
mainfrom
fix/eyegaze-loss-pause-and-filter

Conversation

@willwade

@willwade willwade commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Reframed after review + field-log analysis (title updated): this PR makes Dasher resilient to degraded gaze input and surfaces the degradation — the root cause of the degradation itself (tracker never delivering full-quality data to Dasher) is tracked separately, see the end of this description.

Field-log findings (Tobii IS514, 2.5h diagnostic session)

Evidence Meaning
33 samples/s ceiling, 53–62% validity in good phases Tracker delivered a degraded stream — the data was never sent to Dasher, so Dasher could not "lose" it
resume_device → INSUFFICIENT_PERMISSIONS OEM IS5 sensors need an elevated process to resume the device; unresumed = low-power mode
100 minutes at exactly 2 Hz with 0% valid The tracker's idle heartbeat (sleep state), not the user blinking
WindowsNative gaze failed instantly The OS-mediated path (the one other apps use) never connected on this machine
Invalid samples dropped at the tracker The last gaze position kept driving the zoom through every gap — runaway crosshair (the "quite uncomfortable" report)

Working theory: the tester's other gaze apps work because they use a different access path (Tobii's own elevated stack, or OS Eye Control); Dasher's direct stream-engine connection runs as a second-class client on an unresumed device.

What this PR does (resilience + surfacing)

  1. Gaze-loss hold: invalid samples are forwarded (Tobii + Windows native); the canvas parks the pointer on the crosshair — the engine's zero-velocity point (dasher_get_viewport + dasher_dasher_to_screen, newly P/Invoked) — so the zoom freezes without stopping the engine. The next valid sample simply moves again: no resume click (which gaze users can't make), no persistent settings mutated, no stop-outside interaction. A Render-cadence staleness backstop catches trackers that stop delivering entirely.
  2. Degraded-mode surfacing: trackers raise a new Warning event; resume_device is retried once, then the user gets a toast ("try running Dasher as administrator") + log. Connect now also logs process elevation — the next field log self-interprets the admin experiment.
  3. GazeFilter merged (dead zone / velocity gate / settle window, from the diagnostic work for the transit-zoom report), settings UI included, default on — with two bugs fixed: first-sample phantom velocity (measured against the origin after every Reset), and settle-with-zero-dead-zone never sending (candidate tolerance reused the dead zone).
  4. New GazeFilterTests (10 cases) pin all three filter stages, both regressions, candidate cancellation, and reset semantics.

Next round of logs — what we will look for

  • process elevation = True/False + resume_device result + achieved sample rate → confirms/refutes the elevation theory
  • Gaze lost (…) — holding on crosshair / Gaze resumed pairs → hold cadence vs. blink rate
  • Heartbeat valid/invalid ratios in each phase

Review-loop status

Loop 1 (5/10) caught the original stop-outside design flaw (no resume path, persistent setting mutation, unguarded off-canvas send) — all reworked into the crosshair hold. Filter regressions fixed with named tests.

149/149 tests, build clean.

…) + diagnostic logging

Juan (Argentina, Tobii PCEye + TD Control): "When I look to a letter it
takes some milliseconds till the eye tracker points toward the letter.
Due to this, the experience is a disaster. I believe the problem is
Dasher does not take into account where I am looking at until the eye
tracker points where I intended."

Root cause: OnEyeGazePositionChanged called dasher_mouse_move on every
raw gaze sample with NO filtering — during the transit between targets,
every intermediate position zoomed the canvas toward the wrong place.

New GazeFilter with three configurable stages:

1. DEAD ZONE (default 8px): gaze must move at least N px from the last
   SENT position — reduces jitter noise.

2. VELOCITY GATE (default 3.0 px/ms): if smoothed gaze velocity exceeds
   the threshold, the sample is held (saccade/transit detection). Uses
   exponential smoothing (alpha=0.3) so single-sample spikes don't trigger.

3. SETTLE WINDOW (default 80ms): the gaze must dwell within the dead
   zone for N ms before the position is sent — ensures the user has
   ARRIVED at their target.

Settings UI in Settings > Input > Eye Tracker (visible only when EyeGaze
selected): enable/disable toggle + dead zone and settle time sliders,
saved debounced to access.json and applied live.

Diagnostic logging to %APPDATA%\Dasher\eyegaze.log: every 30th sample
logs position, velocity, and filter statistics (total/sent/dead-zone/
velocity-blocked/settle-blocked counts) — enough to diagnose latency
patterns remotely without flooding the log.

Signed-off-by: will wade <willwade@gmail.com>
…mode

Field-session log (eyegaze.log, Tobii IS514, 2.5h) analysis:

1. GAZE LOSS NEVER STOPPED THE ZOOM. The tracker delivered 40-100%
   invalid samples (blinks, dropouts, and one 100-MINUTE 0%-valid
   stretch at 2Hz), and invalid samples were dropped at the tracker
   - the canvas never learned, so the last gaze position kept
   driving the zoom through every gap. For an eye-tracker user that
   is a runaway crosshair the instant tracking degrades - the
   "quite uncomfortable" experience reported.

2. THE DEVICE RAN IN DEGRADED MODE, SILENTLY. resume_device failed
   with INSUFFICIENT_PERMISSIONS (OEM IS5 sensors need elevation);
   the log shows a 33 samples/s ceiling (30Hz mode) at 53-62%
   validity instead of the device rating, and engine_create also
   failed - all logged, none surfaced to the user.

Fixes:
- Invalid samples are now forwarded (Tobii + Windows native) and the
  canvas pauses the engine immediately via the stop-outside idiom;
  the next valid sample resumes. A Render-cadence staleness backstop
  catches trackers that stop delivering entirely (no invalid samples
  either). Stop When Pointer Leaves is enabled automatically at gaze
  init (required for the pause to hold; logged).
- Trackers raise Warning (new IEyeTrackerService event) for
  degraded-mode conditions; resume_device is retried once, then the
  user gets a toast ("try running Dasher as administrator") + log.
- The GazeFilter (dead zone / velocity gate / settle window, from
  the eyegaze diagnostic work for Juan transit-zoom report) is
  merged with settings, default on, plus a bug fix: the first
  sample after activation/Reset was measured against the origin,
  producing a phantom 8.8 px/ms saccade that blocked the first real
  positions after every gaze-loss recovery (found while writing
  GazeFilterTests). The filter state resets on each loss so
  recovery starts clean.

Also: eyegaze.log statistics tooling (rate/validity/gap analysis)
drove the diagnosis; heartbeat counters remain for the next session.

Signed-off-by: will wade <willwade@gmail.com>
Rework of the pause mechanism after review (5/10): the stop-outside
idiom performs a full engine STOP that needs a Primary_Input click to
resume (LP_START_MODE=none) - a click gaze users cannot make, so
every blink would have halted typing permanently. It also required
force-enabling the PERSISTENT BP_STOP_OUTSIDE parameter (mutating
mouse-mode behaviour forever) and sent -10000 unconditionally.

New design: park the pointer on the crosshair - the engine
zero-velocity point, via dasher_get_viewport +
dasher_dasher_to_screen (new NativeBridge P/Invokes, cached in
Render). The zoom freezes WITHOUT stopping; the next valid sample
simply moves again. No resume path needed, no settings mutated, no
stop-outside interaction. Fallback: canvas centre if the viewport
query fails.

Also (review round 1):
- GazeFilter: settle candidate tolerance is its own (dead zone 0 +
  settle on previously never sent ANYTHING - 1px jitter restarted
  the dwell forever). New regression test.
- GazeFilter class doc order corrected (velocity gate runs first).
- Filter reference captured to a local in the gaze callback
  (ConfigureGazeFilter can swap it on the UI thread).
- New test: first sample not measured against the origin (named for
  the phantom-saccade regression fixed in the previous commit).
- Tobii connect now logs process elevation - the next field log
  self-interprets the administrator/resume_device experiment.

149/149 tests.

Signed-off-by: will wade <willwade@gmail.com>
@willwade willwade changed the title fix(eyegaze): pause zoom on gaze loss, surface degraded tracker mode, merge GazeFilter fix(eyegaze): hold zoom on gaze loss (crosshair park), surface degraded tracker mode, merge GazeFilter Oct 30, 2026
Review loop 2 (6/10): parking on the crosshair X alone froze the zoom
but kept steering the VERTICAL pan - the engine pans toward the
pointer Y at a constant rate (the root-independent screen->Dasher
mapping never self-corrects), so the canvas slid during exactly the
events this PR targets: ~4% of screen height per 250ms blink at
quarter-screen offset and default speed, ~40% at high speeds.
Parking at the full crosshair (dasher 2048,2048) gives m1=m2=0: zero
zoom AND zero pan.

- Park at (chX, chY); _lastGazeY removed (subsumed).
- Crosshair cached at gaze INIT (hold-before-first-Render window
  empty), retried each Render until success, failure logged once.
- The reverse-zooming canvas-centre fallback is GONE: if the cache
  is somehow still empty the hold is skipped once and logged
  (pre-PR behaviour for that instant, self-heals next Render).
- Geometry assumption documented (default/squish exact;
  square_no_xhair would reverse-zoom - not reachable via Dasher UI).
- GazeFilter now fully serialised (_gazeFilterLock): Process on the
  callback thread vs Reset/ConfigureGazeFilter on the UI thread.
- Dead filterToggleRow removed (CS8625).

149/149 tests.

Signed-off-by: will wade <willwade@gmail.com>
@willwade
willwade force-pushed the fix/eyegaze-loss-pause-and-filter branch from b29822a to 4da6ada Compare October 30, 2026 21:23
Settings > Input (eye gaze): "Save eye-tracker diagnostics". Detailed
per-sample lines (Gaze data flowing, RAW struct dumps, gaze filter
stats) now only when enabled; the ALWAYS-ON layer is lifecycle events
only (connect, resume result, elevation status, heartbeat counters,
gaze lost/resumed, tracker warnings) - low volume, exactly what field
triage needs first. Heartbeat notes where to enable detail.

Persisted as GazeDiagnostics in access.json; applied at gaze init and
live from the toggle. The eyegaze.log file is the bug-report
attachment for tracker problems.

149/149 tests.

Signed-off-by: will wade <willwade@gmail.com>
@willwade
willwade merged commit f6333ff into main Oct 30, 2026
2 checks passed
@willwade
willwade deleted the fix/eyegaze-loss-pause-and-filter branch October 30, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant