From 0cf17e26b177b9f53fcb94745b9b8d99f71e82d7 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 22 Sep 2026 20:45:31 +0100 Subject: [PATCH 1/5] feat(eyegaze): gaze filter (dead zone + velocity gate + settle window) + diagnostic logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controls/DasherCanvas.EyeGaze.cs | 45 +++++ src/Dasher.Windows/Controls/SettingsPanel.cs | 105 ++++++++++++ .../Engine/AccessConfiguration.cs | 14 ++ src/Dasher.Windows/EyeGaze/GazeFilter.cs | 155 ++++++++++++++++++ src/Dasher.Windows/Views/MainWindow.axaml.cs | 13 ++ 5 files changed, 332 insertions(+) create mode 100644 src/Dasher.Windows/EyeGaze/GazeFilter.cs diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index ca68408..75ee69b 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -75,6 +75,30 @@ private void DisableEyeGaze(bool blocking) private float _cachedOriginX; private float _cachedOriginY; + private GazeFilter? _gazeFilter; + private long _gazeLogStartMs; + private int _gazeLogInterval; // log every Nth sample (throttles the log) + + /// + /// Configure the gaze filter (dead zone, velocity gate, settle window). + /// Null disables all filtering (raw passthrough — pre-Juan behaviour). + /// + public void ConfigureGazeFilter(double? deadZonePx = null, double? maxVelocity = null, double? settleMs = null) + { + if (deadZonePx == null && maxVelocity == null && settleMs == null) + { + _gazeFilter = null; // disable + EyeGazeLogger.Log("Gaze filter DISABLED (raw passthrough)"); + return; + } + + _gazeFilter ??= new GazeFilter(); + if (deadZonePx.HasValue) _gazeFilter.DeadZonePx = deadZonePx.Value; + if (maxVelocity.HasValue) _gazeFilter.MaxVelocityPxPerMs = maxVelocity.Value; + if (settleMs.HasValue) _gazeFilter.SettleMs = settleMs.Value; + EyeGazeLogger.Log($"Gaze filter: deadZone={_gazeFilter.DeadZonePx}px vel={_gazeFilter.MaxVelocityPxPerMs}px/ms settle={_gazeFilter.SettleMs}ms"); + } + private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) { if (!_useEyeGazeInput || _handle == IntPtr.Zero) return; @@ -91,6 +115,27 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) y = (float)(gazePoint.Y - _cachedOriginY); } + // Apply the gaze filter (dead zone → velocity gate → settle) + if (_gazeFilter != null) + { + var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (!_gazeFilter.Process(x, y, nowMs, out var filteredX, out var filteredY)) + return; // filtered out (in transit, jitter, or not settled) + + // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz) + _gazeLogInterval++; + if (_gazeLogInterval >= 30) + { + _gazeLogInterval = 0; + var f = _gazeFilter; + EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={f.LastVelocity:F1}px/ms " + + $"[total={f.TotalSamples} sent={f.SentSamples} dz={f.DeadZoneBlocks} vel_blk={f.VelocityBlocks} settle_blk={f.SettleBlocks}]"); + } + + x = (float)filteredX; + y = (float)filteredY; + } + NativeBridge.dasher_mouse_move(_handle, x, y); } diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 98fc9a7..aa6658a 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -623,6 +623,97 @@ void PopulateSelections(AccessMethod method) Margin = new Thickness(0, 2, 0, 0), }; + // ── Gaze filter controls (transit zoom fix; tune for eye tracker latency) ── + var filterHeader = new TextBlock + { + Text = "Gaze Smoothing", + FontSize = 12, + FontWeight = FontWeight.Medium, + Foreground = BrushLabel, + Margin = new Thickness(0, 10, 0, 0), + }; + + var filterToggle = new ToggleSwitch + { + IsChecked = config.GazeFilterEnabled, + OnContent = "", OffContent = "", MinWidth = 0, Height = 24, + }; + var filterToggleRow = BuildToggleRow("Filter gaze", config.GazeFilterEnabled, null); + var filterToggleLabel = new TextBlock + { + Text = "Filter gaze (reduces zoom toward wrong positions during eye transit)", + FontSize = 12, + Foreground = BrushValue, + VerticalAlignment = VerticalAlignment.Center, + }; + var filterTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + filterTogglePanel.Children.Add(filterToggle); + filterTogglePanel.Children.Add(filterToggleLabel); + + var gazeFilterHelp = new TextBlock + { + Text = "Reduces zoom toward wrong positions while your eyes move to a target", + FontSize = 11, + Foreground = BrushMuted, + TextWrapping = TextWrapping.Wrap, + }; + + var deadZoneSlider = new Slider + { + Minimum = 0, Maximum = 30, Value = config.GazeDeadZonePx, + Width = 180, FontSize = 12, + }; + var deadZoneLabel = new TextBlock { Text = $"Dead zone: {config.GazeDeadZonePx:F0} px", FontSize = 11, Foreground = BrushValue }; + deadZoneSlider.PropertyChanged += (s, e) => + { + if (e.Property.Name == "Value") + deadZoneLabel.Text = $"Dead zone: {deadZoneSlider.Value:F0} px"; + }; + + var settleSlider = new Slider + { + Minimum = 0, Maximum = 300, Value = config.GazeSettleMs, + Width = 180, FontSize = 12, + }; + var settleLabel = new TextBlock { Text = $"Settle time: {config.GazeSettleMs:F0} ms", FontSize = 11, Foreground = BrushValue }; + settleSlider.PropertyChanged += (s, e) => + { + if (e.Property.Name == "Value") + settleLabel.Text = $"Settle time: {settleSlider.Value:F0} ms"; + }; + + void SaveFilterConfig() + { + var cfg = AccessConfiguration.Load(); + cfg.GazeFilterEnabled = filterToggle.IsChecked ?? true; + cfg.GazeDeadZonePx = deadZoneSlider.Value; + cfg.GazeSettleMs = settleSlider.Value; + cfg.Save(); + // Apply live if canvas is available + var canvas = this.FindControl("DasherCanvas"); + if (canvas != null) + { + if (cfg.GazeFilterEnabled) + canvas.ConfigureGazeFilter(cfg.GazeDeadZonePx, null, cfg.GazeSettleMs); + else + canvas.ConfigureGazeFilter(); // disable + } + } + + // Debounced save on slider change + Avalonia.Threading.DispatcherTimer? filterSaveTimer = null; + void ScheduleFilterSave() + { + filterSaveTimer?.Stop(); + filterSaveTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + filterSaveTimer.Tick += (_, _) => { filterSaveTimer!.Stop(); SaveFilterConfig(); }; + filterSaveTimer.Start(); + } + + deadZoneSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); }; + settleSlider.PropertyChanged += (s, e) => { if (e.Property.Name == "Value") ScheduleFilterSave(); }; + filterToggle.PropertyChanged += (s, e) => { if (e.Property.Name == "IsChecked") ScheduleFilterSave(); }; + void UpdateTrackerVisibility() { var isEyeGaze = methods[methodCombo.SelectedIndex] == AccessMethod.EyeGaze; @@ -631,6 +722,13 @@ void UpdateTrackerVisibility() var isUdp = isEyeGaze && trackerCombo.SelectedIndex == 1; portPanel.IsVisible = isUdp; trackerHelp.IsVisible = false; + filterHeader.IsVisible = isEyeGaze; + filterTogglePanel.IsVisible = isEyeGaze; + gazeFilterHelp.IsVisible = isEyeGaze; + deadZoneSlider.IsVisible = isEyeGaze; + deadZoneLabel.IsVisible = isEyeGaze; + settleSlider.IsVisible = isEyeGaze; + settleLabel.IsVisible = isEyeGaze; if (!isEyeGaze) return; @@ -806,6 +904,13 @@ void UpdateTrackerVisibility() panel.Children.Add(trackerCombo); panel.Children.Add(portPanel); panel.Children.Add(trackerHelp); + panel.Children.Add(filterHeader); + panel.Children.Add(filterTogglePanel); + panel.Children.Add(gazeFilterHelp); + panel.Children.Add(deadZoneSlider); + panel.Children.Add(deadZoneLabel); + panel.Children.Add(settleSlider); + panel.Children.Add(settleLabel); panel.Children.Add(filterLabel); panel.Children.Add(filterCombo); panel.Children.Add(filterHelp); diff --git a/src/Dasher.Windows/Engine/AccessConfiguration.cs b/src/Dasher.Windows/Engine/AccessConfiguration.cs index 7816d3e..7a96883 100644 --- a/src/Dasher.Windows/Engine/AccessConfiguration.cs +++ b/src/Dasher.Windows/Engine/AccessConfiguration.cs @@ -11,6 +11,20 @@ public class AccessConfiguration public string EyeTrackerType { get; set; } = "WindowsNative"; public int UdpPort { get; set; } = 5555; + // ── Gaze filter (transit zoom fix; Juan's Tobii PCEye + TD Control) ──── + + /// Minimum px movement before sending (0 = always send). + public double GazeDeadZonePx { get; set; } = 8; + + /// Maximum velocity px/ms before holding (0 = disabled). + public double GazeMaxVelocityPxPerMs { get; set; } = 3.0; + + /// Ms the gaze must dwell before sending (0 = immediate). + public double GazeSettleMs { get; set; } = 80; + + /// Master switch: false = raw passthrough (pre-filter behaviour). + public bool GazeFilterEnabled { get; set; } = true; + /// /// The engine input filter (SP_INPUT_FILTER). MIRROR ONLY — the engine's /// own persistence (dasher_settings.xml, DasherCore #76 / v0.2.16+) owns diff --git a/src/Dasher.Windows/EyeGaze/GazeFilter.cs b/src/Dasher.Windows/EyeGaze/GazeFilter.cs new file mode 100644 index 0000000..616b428 --- /dev/null +++ b/src/Dasher.Windows/EyeGaze/GazeFilter.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; + +namespace Dasher.Windows.EyeGaze; + +/// +/// Filters raw gaze data before it reaches the engine's zoom. Solves the +/// "transit zoom" problem reported by eye-tracker users (Juan, Argentina, +/// Tobii PCEye + TD Control): when the user looks at a letter, the tracker +/// takes milliseconds to move its cursor there, and every intermediate +/// position was sent to dasher_mouse_move — zooming the canvas toward the +/// wrong places during transit. +/// +/// Three filters, in order: +/// +/// 1. DEAD ZONE: if the gaze hasn't moved more than N screen px from the +/// last SENT position, don't send — reduces jitter noise. +/// +/// 2. VELOCITY GATE: if the gaze is moving faster than N px/ms, it's in +/// transit (saccade) — hold the last sent position until velocity drops. +/// +/// 3. SETTLE WINDOW: the gaze must stay within the dead zone for N ms +/// before the position is sent — ensures the user has ARRIVED at their +/// target, not just passing through. +/// +/// All thresholds configurable for experimentation; 0 disables each filter. +/// +public sealed class GazeFilter +{ + // ── Configuration (all tunable; 0 = disabled) ───────────────────────── + + /// Minimum px movement before sending (0 = always send). Default 8. + public double DeadZonePx { get; set; } = 8; + + /// Maximum velocity px/ms before positions are held (0 = disabled). Default 3.0. + public double MaxVelocityPxPerMs { get; set; } = 3.0; + + /// Ms the gaze must dwell before sending (0 = send immediately). Default 80. + public double SettleMs { get; set; } = 80; + + // ── State ─────────────────────────────────────────────────────────────── + + private double _lastSentX, _lastSentY; + private double _candidateX, _candidateY; + private long _candidateStartMs; + private bool _hasCandidate; + private long _lastSampleMs; + private double _lastSampleX, _lastSampleY; + private double _smoothedVelocity; + private bool _hasSent; + + // Diagnostics (for the eyegaze log) + public int TotalSamples { get; private set; } + public int SentSamples { get; private set; } + public int DeadZoneBlocks { get; private set; } + public int VelocityBlocks { get; private set; } + public int SettleBlocks { get; private set; } + public double LastVelocity { get; private set; } + + /// + /// Process a raw gaze sample. Returns true when the position should be + /// sent to the engine (passed all filters), with the filtered position + /// in the out parameters. + /// + public bool Process(double x, double y, long timestampMs, out double outX, out double outY) + { + outX = x; outY = y; + TotalSamples++; + + // Compute instantaneous velocity (px per ms) + var dtMs = _lastSampleMs == 0 ? 16 : Math.Max(1, timestampMs - _lastSampleMs); + var dx = x - _lastSampleX; + var dy = y - _lastSampleY; + var distance = Math.Sqrt(dx * dx + dy * dy); + var velocity = distance / dtMs; + // Exponential smoothing (alpha=0.3) — single-sample spikes don't + // trigger the gate; sustained transit does + _smoothedVelocity = _lastSampleMs == 0 ? velocity : _smoothedVelocity * 0.7 + velocity * 0.3; + LastVelocity = _smoothedVelocity; + _lastSampleMs = timestampMs; + _lastSampleX = x; + _lastSampleY = y; + + // FILTER 1: velocity gate — in transit, hold position + if (MaxVelocityPxPerMs > 0 && _smoothedVelocity > MaxVelocityPxPerMs) + { + VelocityBlocks++; + _hasCandidate = false; // cancel any pending settle + return false; + } + + // FILTER 2: dead zone — too close to last sent position + if (_hasSent && DeadZonePx > 0) + { + var sentDx = x - _lastSentX; + var sentDy = y - _lastSentY; + if (Math.Sqrt(sentDx * sentDx + sentDy * sentDy) < DeadZonePx) + { + DeadZoneBlocks++; + _hasCandidate = false; // within dead zone — reset settle + return false; + } + } + + // FILTER 3: settle window — must dwell for N ms + if (SettleMs <= 0) + { + // No settle required — send immediately + Send(x, y, ref outX, ref outY); + return true; + } + + if (!_hasCandidate || Math.Abs(x - _candidateX) > DeadZonePx || Math.Abs(y - _candidateY) > DeadZonePx) + { + // New candidate position — start the settle timer + _candidateX = x; + _candidateY = y; + _candidateStartMs = timestampMs; + _hasCandidate = true; + SettleBlocks++; + return false; + } + + // Same candidate — check if settled long enough + if (timestampMs - _candidateStartMs >= SettleMs) + { + Send(_candidateX, _candidateY, ref outX, ref outY); + _hasCandidate = false; + return true; + } + + SettleBlocks++; + return false; + } + + private void Send(double x, double y, ref double outX, ref double outY) + { + _lastSentX = x; + _lastSentY = y; + _hasSent = true; + SentSamples++; + outX = x; + outY = y; + } + + /// Reset all state (mode switch, re-calibration). + public void Reset() + { + _hasCandidate = false; + _hasSent = false; + _lastSampleMs = 0; + _smoothedVelocity = 0; + TotalSamples = SentSamples = DeadZoneBlocks = VelocityBlocks = SettleBlocks = 0; + } +} diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 539722b..7594b6d 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -714,6 +714,19 @@ private async void RestoreEyeGazeFromConfig() if (_canvas == null) return; _canvas.DisableJoystick(); + // Apply the gaze filter configuration (transit zoom fix) + if (config.GazeFilterEnabled) + { + _canvas.ConfigureGazeFilter( + config.GazeDeadZonePx, + config.GazeMaxVelocityPxPerMs, + config.GazeSettleMs); + } + else + { + _canvas.ConfigureGazeFilter(); // disable (raw passthrough) + } + var ok = await _canvas.InitializeEyeGazeAsync(trackerType, config.UdpPort); if (!ok) { From 339c25af23f5ea540a368f4235395c44fa5c55bd Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 30 Oct 2026 19:28:41 +0000 Subject: [PATCH 2/5] fix(eyegaze): pause zoom when gaze is lost; surface degraded tracker 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 --- .../Controls/DasherCanvas.EyeGaze.cs | 91 ++++++++++++- src/Dasher.Windows/Controls/DasherCanvas.cs | 3 + .../Engine/EyeGazeIntegration.cs | 7 + .../EyeGaze/EyetuitiveTracker.cs | 1 + src/Dasher.Windows/EyeGaze/GazeFilter.cs | 30 +++-- .../EyeGaze/IEyeTrackerService.cs | 8 ++ .../EyeGaze/TobiiStreamEngineTracker.cs | 35 ++++- src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs | 1 + .../EyeGaze/WindowsGazeTracker.cs | 9 +- tests/Dasher.Windows.Tests/GazeFilterTests.cs | 121 ++++++++++++++++++ 10 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 tests/Dasher.Windows.Tests/GazeFilterTests.cs diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index 75ee69b..84c4f2a 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -43,11 +43,40 @@ public async Task InitializeEyeGazeAsync(EyeGazeIntegration.TrackerType tr { _useEyeGazeInput = true; _eyeGazeIntegration.GazePositionChanged += OnEyeGazePositionChanged; + _eyeGazeIntegration.Warning += OnEyeTrackerWarning; + _gazePausedForLoss = false; + _lastGazeTicks = 0; + + // Eye gaze NEEDS stop-outside semantics: when tracking is + // lost (blink, dropout, tracker losing the eyes) the last + // gaze position must stop driving the zoom. Without it, + // every invalid gap is a runaway zoom at the last spot — + // the eyegaze.log field session showed a 100-minute + // 0%-valid stretch where nothing told the engine to stop. + var stopKey = NativeBridge.dasher_find_parameter_key("BP_STOP_OUTSIDE"); + if (stopKey >= 0 && NativeBridge.dasher_get_bool_parameter(_handle, stopKey) == 0) + { + NativeBridge.dasher_set_bool_parameter(_handle, stopKey, 1); + EyeGazeLogger.Log("Eye gaze: enabled 'Stop When Pointer Leaves' (required for gaze-loss pausing)"); + } + EyeGazeLogger.Log("Eye gaze input active — indicator should appear (green/red dot top-right)"); } return ok; } + /// Tracker-side degradation notice (thread-pool thread): + /// log + one toast per session, marshalled to the UI thread. + private bool _gazeWarned; + private void OnEyeTrackerWarning(string message) + { + EyeGazeLogger.Log($"Tracker warning: {message}"); + if (_gazeWarned) return; + _gazeWarned = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + Services.ToastNotifier.Show("Eye tracker notice", message, isWarning: true)); + } + public void DisableEyeGaze() { DisableEyeGaze(blocking: true); @@ -62,9 +91,12 @@ private void DisableEyeGaze(bool blocking) { _useEyeGazeInput = false; _eyeTrackActive = false; + _gazePausedForLoss = false; + _gazeWarned = false; if (_eyeGazeIntegration != null) { _eyeGazeIntegration.GazePositionChanged -= OnEyeGazePositionChanged; + _eyeGazeIntegration.Warning -= OnEyeTrackerWarning; _eyeGazeIntegration.Shutdown(blocking); _eyeGazeIntegration = null; } @@ -103,8 +135,23 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) { if (!_useEyeGazeInput || _handle == IntPtr.Zero) return; + if (!gazePoint.IsValid) + { + // Tracker says eyes not found: pause immediately (the same + // stop-outside idiom as the pointer leaving the canvas). + // Dropping invalid samples silently left the last gaze + // position driving the zoom (the eyegaze.log field session). + PauseForGazeLoss("invalid sample"); + return; + } + _lastGazeTicks = DateTimeOffset.UtcNow.Ticks; _eyeTrackActive = true; + if (_gazePausedForLoss) + { + _gazePausedForLoss = false; + EyeGazeLogger.Log("Gaze resumed — zoom continues"); + } float x = gazePoint.X; float y = gazePoint.Y; @@ -139,6 +186,47 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) NativeBridge.dasher_mouse_move(_handle, x, y); } + /// True while the engine is paused because gaze was lost; + /// the next valid sample clears it. + private volatile bool _gazePausedForLoss; + + /// + /// Pause the engine because gaze was lost: send the off-canvas + /// position (the engine's stop-outside idiom — enabled at gaze init), + /// flag the indicator red, and reset the gaze filter so stale + /// transit state doesn't fire the moment gaze returns. Called from + /// the gaze callback thread (invalid samples) and the UI thread + /// (staleness backstop in Render). + /// + private void PauseForGazeLoss(string reason) + { + _eyeTrackActive = false; + _gazeFilter?.Reset(); + if (_gazePausedForLoss) return; // already paused + _gazePausedForLoss = true; + EyeGazeLogger.Log($"Gaze lost ({reason}) — pausing zoom"); + try + { + NativeBridge.dasher_mouse_move(_handle, -10000f, -10000f); + } + catch (Exception ex) + { + EyeGazeLogger.Log($"PauseForGazeLoss: mouse_move failed: {ex.Message}"); + } + } + + /// Staleness backstop: called from Render (UI thread, RAF + /// cadence) — if no valid sample arrived within GazeTimeout (e.g. + /// the tracker stopped delivering entirely), pause. The indicator's + /// red dot rides the same check. + private void CheckGazeStaleness() + { + if (!_useEyeGazeInput) return; + if (_lastGazeTicks == 0 || DateTimeOffset.UtcNow - new DateTimeOffset(_lastGazeTicks, TimeSpan.Zero) <= GazeTimeout) + return; + PauseForGazeLoss("no gaze data for " + GazeTimeout.TotalMilliseconds + "ms"); + } + private void DrawEyeTrackIndicator(DrawingContext context) { if (!_useEyeGazeInput) return; @@ -156,7 +244,8 @@ private void DrawEyeTrackIndicator(DrawingContext context) var color = _eyeTrackActive ? Color.FromRgb(80, 200, 80) // green - : Color.FromRgb(200, 60, 60); // red + : Color.FromRgb(200, 60, 60) // red + ; context.DrawEllipse( new SolidColorBrush(color), diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs index ed9368d..f12ddfe 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.cs @@ -233,6 +233,9 @@ public override void Render(DrawingContext context) var scaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1.0; _cachedOriginX = (float)(origin.X / scaling); _cachedOriginY = (float)(origin.Y / scaling); + // Staleness backstop: catches a tracker that stops delivering + // entirely (no invalid samples either). + CheckGazeStaleness(); } DrawEyeTrackIndicator(context); diff --git a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs index 39cbae1..ac790cd 100644 --- a/src/Dasher.Windows/Engine/EyeGazeIntegration.cs +++ b/src/Dasher.Windows/Engine/EyeGazeIntegration.cs @@ -26,6 +26,11 @@ public class Settings } public event EventHandler? GazePositionChanged; + + /// Forwarded tracker warnings (degraded mode, permissions) + /// — raised on the tracker's thread (see IEyeTrackerService.Warning). + public event Action? Warning; + public bool IsActive => _device?.IsEnabled ?? false; public async Task InitializeAsync(Settings settings) @@ -41,6 +46,8 @@ public async Task InitializeAsync(Settings settings) return false; } + tracker.Warning += w => Warning?.Invoke(w); + _device = new EyeGazeInputDevice(); var ok = await _device.InitializeAsync(tracker); if (ok) diff --git a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs index ab1f4a7..0f5acb0 100644 --- a/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs +++ b/src/Dasher.Windows/EyeGaze/EyetuitiveTracker.cs @@ -16,6 +16,7 @@ public sealed class EyetuitiveTracker : IEyeTrackerService private bool _disposed; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "eyetuitive"; public bool IsConnected { get; private set; } diff --git a/src/Dasher.Windows/EyeGaze/GazeFilter.cs b/src/Dasher.Windows/EyeGaze/GazeFilter.cs index 616b428..68abbfe 100644 --- a/src/Dasher.Windows/EyeGaze/GazeFilter.cs +++ b/src/Dasher.Windows/EyeGaze/GazeFilter.cs @@ -67,15 +67,27 @@ public bool Process(double x, double y, long timestampMs, out double outX, out d outX = x; outY = y; TotalSamples++; - // Compute instantaneous velocity (px per ms) - var dtMs = _lastSampleMs == 0 ? 16 : Math.Max(1, timestampMs - _lastSampleMs); - var dx = x - _lastSampleX; - var dy = y - _lastSampleY; - var distance = Math.Sqrt(dx * dx + dy * dy); - var velocity = distance / dtMs; - // Exponential smoothing (alpha=0.3) — single-sample spikes don't - // trigger the gate; sustained transit does - _smoothedVelocity = _lastSampleMs == 0 ? velocity : _smoothedVelocity * 0.7 + velocity * 0.3; + // Compute instantaneous velocity (px per ms). The FIRST sample after + // construction/Reset must not be measured against the origin (0,0) — + // that phantom "saccade" (e.g. 8.8 px/ms for a first gaze at + // (100,100)) blocked the first real positions after every gaze-loss + // recovery; found by GazeFilterTests while pinning the semantics. + double velocity = 0; + if (_lastSampleMs != 0) + { + var dtMs = Math.Max(1, timestampMs - _lastSampleMs); + var dx = x - _lastSampleX; + var dy = y - _lastSampleY; + var distance = Math.Sqrt(dx * dx + dy * dy); + velocity = distance / dtMs; + // Exponential smoothing (alpha=0.3) — single-sample spikes don't + // trigger the gate; sustained transit does + _smoothedVelocity = _smoothedVelocity * 0.7 + velocity * 0.3; + } + else + { + _smoothedVelocity = 0; + } LastVelocity = _smoothedVelocity; _lastSampleMs = timestampMs; _lastSampleX = x; diff --git a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs index 23c419a..a9743d8 100644 --- a/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs +++ b/src/Dasher.Windows/EyeGaze/IEyeTrackerService.cs @@ -6,6 +6,14 @@ namespace Dasher.Windows.EyeGaze public interface IEyeTrackerService : IDisposable { event EventHandler GazeDataReceived; + + /// + /// Non-fatal tracker condition the user should know about (degraded + /// mode, permissions, licensing) — surfaces as a toast + log line. + /// Raised from the tracker's connect/callback thread. + /// + event Action? Warning; + string TrackerName { get; } bool IsConnected { get; } Task ConnectAsync(); diff --git a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs index f647ee3..0a6de1b 100644 --- a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs +++ b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs @@ -106,6 +106,7 @@ private static string GetTobiiError(int code) private bool _disposed; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "Tobii (Stream Engine)"; public bool IsConnected { get; private set; } @@ -126,7 +127,7 @@ private static void DeviceUrlCallback(string url, IntPtr user_data) public Task ConnectAsync() { - return Task.Run(() => + return Task.Run(async () => { try { @@ -209,9 +210,30 @@ public Task ConnectAsync() int ssResult = tobii_stream_supported(_device, 0, out bool gazeSupported); EyeGazeLogger.Log($"Tobii: Gaze point stream supported = {gazeSupported} (result code {ssResult})"); - // Resume device in case it's paused + // Resume device in case it's paused. Some OEM sensors (IS5/ + // Dynavox, e.g. IS514) require elevation for this — a + // permissions failure leaves them in a degraded low-rate, + // low-accuracy mode (one field session measured a 30 Hz + // ceiling at 60% validity instead of 90 Hz). Retry once, + // then surface the degradation to the user. int rdResult = tobii_resume_device(_device); - EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}{(rdResult != 0 ? $": {GetTobiiError(rdResult)}" : "")}"); + if (rdResult != 0) + { + EyeGazeLogger.Log($"Tobii: resume_device result = {rdResult}: {GetTobiiError(rdResult)} — retrying once in 500ms"); + await Task.Delay(500); + rdResult = tobii_resume_device(_device); + } + if (rdResult != 0) + { + EyeGazeLogger.Log($"Tobii: resume_device still failing ({rdResult}: {GetTobiiError(rdResult)}) — continuing in possibly degraded mode"); + Warning?.Invoke( + $"The eye tracker could not be fully resumed ({GetTobiiError(rdResult)}). " + + "Tracking may run at reduced speed and accuracy — try running Dasher as administrator."); + } + else + { + EyeGazeLogger.Log("Tobii: resume_device ok"); + } return true; } catch (Exception ex) @@ -467,6 +489,13 @@ private void OnGazePoint(ref tobii_gaze_point_t gaze_point) { Interlocked.Increment(ref _invalidCount); EyeGazeLogger.LogGazeData(gaze_point.x, gaze_point.y, valid: false); + // Forward the LOSS itself: the canvas pauses the engine on + // invalid samples (dropping them silently left the last gaze + // position driving the zoom — the eyegaze.log field session + // showed a 100-minute 0%-valid stretch). + GazeDataReceived?.Invoke(this, new GazePoint( + gaze_point.x * ScreenWidth, gaze_point.y * ScreenHeight, + DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true)); return; } diff --git a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs index 85fb3e8..73d54b9 100644 --- a/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs +++ b/src/Dasher.Windows/EyeGaze/UdpGazeTracker.cs @@ -15,6 +15,7 @@ public class UdpGazeTracker : IEyeTrackerService private Task? _receiveTask; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "UDP Gaze Tracker"; public bool IsConnected { get; private set; } public int UdpPort { get; set; } = 5555; diff --git a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs index c4cb620..8163c1f 100644 --- a/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs +++ b/src/Dasher.Windows/EyeGaze/WindowsGazeTracker.cs @@ -10,6 +10,7 @@ public sealed class WindowsGazeTracker : IEyeTrackerService private bool _isTracking; public event EventHandler? GazeDataReceived; + public event Action? Warning; public string TrackerName => "Windows Eye Tracker"; public bool IsConnected { get; private set; } @@ -57,7 +58,13 @@ private void OnGazeMoved(GazeInputSourcePreview sender, GazeMovedPreviewEventArg var point = args.CurrentPoint; var pos = point.EyeGazePosition; - if (pos == null) return; + if (pos == null) + { + // No gaze for this frame (eyes not found) — forward the loss + // so the canvas can pause the engine (see TobiiStreamEngine). + GazeDataReceived?.Invoke(this, new GazePoint(-1, -1, DateTimeOffset.UtcNow, isValid: false, isScreenCoordinates: true)); + return; + } var coords = pos.Value; GazeDataReceived?.Invoke(this, new GazePoint( diff --git a/tests/Dasher.Windows.Tests/GazeFilterTests.cs b/tests/Dasher.Windows.Tests/GazeFilterTests.cs new file mode 100644 index 0000000..31595c0 --- /dev/null +++ b/tests/Dasher.Windows.Tests/GazeFilterTests.cs @@ -0,0 +1,121 @@ +using Dasher.Windows.EyeGaze; + +namespace Dasher.Windows.Tests; + +/// +/// GazeFilter (dead zone + velocity gate + settle window) — the transit-zoom +/// fix from the eyegaze diagnostic work (Juan's report; validated against the +/// eyegaze.log field session). Pure class, deterministic timestamps. +/// +public class GazeFilterTests +{ + private static long T(int ms) => 1_000_000 + ms; // arbitrary epoch offset + + [Fact] + public void First_sample_with_no_settle_sent_immediately() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out var x, out var y)); + Assert.Equal(100, x); + Assert.Equal(100, y); + } + + [Fact] + public void Dead_zone_blocks_jitter_around_last_sent() + { + var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); // sent at (100,100) + Assert.False(f.Process(105, 103, T(16), out _, out _)); // 5.8px < 10px: jitter + Assert.False(f.Process(108, 104, T(32), out _, out _)); // still inside + Assert.Equal(1, f.SentSamples); + Assert.Equal(2, f.DeadZoneBlocks); + } + + [Fact] + public void Movement_beyond_dead_zone_passes() + { + var f = new GazeFilter { DeadZonePx = 10, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.True(f.Process(130, 100, T(16), out var x, out _)); // 30px jump + Assert.Equal(130, x); + } + + [Fact] + public void Velocity_gate_holds_position_during_saccade_transit() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + // 100px in 16ms = 6.25 px/ms — well over the gate. The smoothed + // velocity (0.7*0 + 0.3*6.25 = 1.9) is still under on the FIRST + // transit sample, then crosses it. + Assert.True(f.Process(200, 100, T(16), out _, out _)); // smoothing lags + Assert.False(f.Process(300, 100, T(32), out _, out _)); // gate active + Assert.False(f.Process(400, 100, T(48), out _, out _)); // still in transit + // Arrive: dwell at the target. The smoothed velocity decays + // exponentially (alpha 0.3) — needs a few stationary samples to + // cross back under the gate. + var sent = false; + for (var t = 100; t <= 300 && !sent; t += 16) + sent = f.Process(401, 100, T(t), out _, out _); + Assert.True(sent); + Assert.True(f.VelocityBlocks >= 1); + } + + [Fact] + public void Settle_window_requires_dwell_before_sending() + { + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 }; + // First sample becomes the settle CANDIDATE — nothing is sent until + // the gaze dwells on it for SettleMs. + Assert.False(f.Process(100, 100, T(0), out _, out _)); + Assert.False(f.Process(102, 101, T(40), out _, out _)); // dwelling, 40ms < 80ms + // 90ms dwell on the same candidate: sent — at the CANDIDATE + // position where the dwell began, not the latest sample. + Assert.True(f.Process(103, 101, T(90), out var x, out var y)); + Assert.Equal(100, x); + Assert.Equal(100, y); + } + + [Fact] + public void Settle_candidate_resets_when_gaze_moves_on() + { + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 0, SettleMs = 80 }; + f.Process(100, 100, T(0), out _, out _); // candidate (100,100) + f.Process(250, 100, T(40), out _, out _); // moved way off: NEW candidate + // 60ms into the new candidate's dwell — still not settled + Assert.False(f.Process(251, 100, T(100), out _, out _)); + // ...and the original (100,100) never got sent at 90ms (it was + // cancelled when the gaze moved). + Assert.Equal(0, f.SentSamples); + } + + [Fact] + public void Reset_clears_state_for_a_fresh_session() + { + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.Equal(1, f.SentSamples); + f.Reset(); + Assert.Equal(0, f.SentSamples); + Assert.Equal(0, f.TotalSamples); + // After reset the filter behaves as new: first sample sends (no + // dead-zone reference, no velocity baseline). + Assert.True(f.Process(500, 500, T(1000), out _, out _)); + } + + [Fact] + public void Velocity_gate_cancels_pending_settle() + { + // Dwelling at a candidate completes and sends; a saccade after a NEW + // candidate begins must cancel the dwell — nothing sent mid-transit. + var f = new GazeFilter { DeadZonePx = 8, MaxVelocityPxPerMs = 1.0, SettleMs = 50 }; + Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate starts + Assert.True(f.Process(100, 100, T(60), out _, out _)); // dwell completed: sent + Assert.Equal(1, f.SentSamples); + // New target: candidate starts, then transit begins before dwell + f.Process(400, 100, T(80), out _, out _); // fast move: gate or new candidate + var sentBefore = f.SentSamples; + Assert.False(f.Process(500, 100, T(96), out _, out _)); // transit: held + Assert.Equal(sentBefore, f.SentSamples); + } +} From ed7a8315cd60e901cbaf4d17abf556ef63f1a4d1 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 30 Oct 2026 20:57:17 +0000 Subject: [PATCH 3/5] fix(eyegaze): hold zoom on the crosshair (no engine stop) on gaze loss 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 --- .../Controls/DasherCanvas.EyeGaze.cs | 66 +++++++++---------- src/Dasher.Windows/Controls/DasherCanvas.cs | 16 +++++ src/Dasher.Windows/Engine/NativeBridge.cs | 24 +++++++ src/Dasher.Windows/EyeGaze/GazeFilter.cs | 28 ++++++-- .../EyeGaze/TobiiStreamEngineTracker.cs | 21 ++++++ tests/Dasher.Windows.Tests/GazeFilterTests.cs | 31 +++++++++ 6 files changed, 146 insertions(+), 40 deletions(-) diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index 84c4f2a..8fad835 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -46,20 +46,6 @@ public async Task InitializeEyeGazeAsync(EyeGazeIntegration.TrackerType tr _eyeGazeIntegration.Warning += OnEyeTrackerWarning; _gazePausedForLoss = false; _lastGazeTicks = 0; - - // Eye gaze NEEDS stop-outside semantics: when tracking is - // lost (blink, dropout, tracker losing the eyes) the last - // gaze position must stop driving the zoom. Without it, - // every invalid gap is a runaway zoom at the last spot — - // the eyegaze.log field session showed a 100-minute - // 0%-valid stretch where nothing told the engine to stop. - var stopKey = NativeBridge.dasher_find_parameter_key("BP_STOP_OUTSIDE"); - if (stopKey >= 0 && NativeBridge.dasher_get_bool_parameter(_handle, stopKey) == 0) - { - NativeBridge.dasher_set_bool_parameter(_handle, stopKey, 1); - EyeGazeLogger.Log("Eye gaze: enabled 'Stop When Pointer Leaves' (required for gaze-loss pausing)"); - } - EyeGazeLogger.Log("Eye gaze input active — indicator should appear (green/red dot top-right)"); } return ok; @@ -108,7 +94,6 @@ private void DisableEyeGaze(bool blocking) private float _cachedOriginY; private GazeFilter? _gazeFilter; - private long _gazeLogStartMs; private int _gazeLogInterval; // log every Nth sample (throttles the log) /// @@ -161,12 +146,16 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) x = (float)(gazePoint.X - _cachedOriginX); y = (float)(gazePoint.Y - _cachedOriginY); } + _lastGazeY = y; - // Apply the gaze filter (dead zone → velocity gate → settle) - if (_gazeFilter != null) + // Apply the gaze filter (velocity gate → dead zone → settle). + // Local capture: ConfigureGazeFilter runs on the UI thread and + // can swap the field to null between check and call. + var filter = _gazeFilter; + if (filter != null) { var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (!_gazeFilter.Process(x, y, nowMs, out var filteredX, out var filteredY)) + if (!filter.Process(x, y, nowMs, out var filteredX, out var filteredY)) return; // filtered out (in transit, jitter, or not settled) // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz) @@ -174,9 +163,8 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) if (_gazeLogInterval >= 30) { _gazeLogInterval = 0; - var f = _gazeFilter; - EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={f.LastVelocity:F1}px/ms " + - $"[total={f.TotalSamples} sent={f.SentSamples} dz={f.DeadZoneBlocks} vel_blk={f.VelocityBlocks} settle_blk={f.SettleBlocks}]"); + EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={filter.LastVelocity:F1}px/ms " + + $"[total={filter.TotalSamples} sent={filter.SentSamples} dz={filter.DeadZoneBlocks} vel_blk={filter.VelocityBlocks} settle_blk={filter.SettleBlocks}]"); } x = (float)filteredX; @@ -186,28 +174,40 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) NativeBridge.dasher_mouse_move(_handle, x, y); } - /// True while the engine is paused because gaze was lost; - /// the next valid sample clears it. + /// True while the engine is parked on the crosshair because + /// gaze was lost; the next valid sample clears it. private volatile bool _gazePausedForLoss; + /// Last valid gaze Y in canvas-local DIPs (the hold keeps + /// it so the freeze doesn't jump the Y axis). + private float _lastGazeY; + + /// Screen X of the crosshair in engine screen space — the + /// zero-velocity point. Cached by Render (UI thread); the hold reads + /// it from the gaze callback thread. + private volatile float _cachedCrosshairScreenX; + /// - /// Pause the engine because gaze was lost: send the off-canvas - /// position (the engine's stop-outside idiom — enabled at gaze init), - /// flag the indicator red, and reset the gaze filter so stale - /// transit state doesn't fire the moment gaze returns. Called from - /// the gaze callback thread (invalid samples) and the UI thread - /// (staleness backstop in Render). + /// Park the pointer on the crosshair: the crosshair is the engine's + /// zero-velocity point, so the zoom freezes WITHOUT stopping the + /// engine — no resume click is needed and the next valid sample + /// simply moves again. (The stop-outside idiom was rejected in + /// review: BP_STOP_OUTSIDE performs a full engine stop that needs a + /// Primary_Input click to resume — a click gaze users can't make; + /// every blink would have halted typing.) /// private void PauseForGazeLoss(string reason) { _eyeTrackActive = false; - _gazeFilter?.Reset(); - if (_gazePausedForLoss) return; // already paused + var filter = _gazeFilter; + filter?.Reset(); + if (_gazePausedForLoss) return; // already holding _gazePausedForLoss = true; - EyeGazeLogger.Log($"Gaze lost ({reason}) — pausing zoom"); + EyeGazeLogger.Log($"Gaze lost ({reason}) — holding on crosshair"); try { - NativeBridge.dasher_mouse_move(_handle, -10000f, -10000f); + var holdX = _cachedCrosshairScreenX != 0 ? _cachedCrosshairScreenX : (float)(Bounds.Width / 2); + NativeBridge.dasher_mouse_move(_handle, holdX, _lastGazeY); } catch (Exception ex) { diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs index f12ddfe..d9776dc 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.cs @@ -233,6 +233,22 @@ public override void Render(DrawingContext context) var scaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1.0; _cachedOriginX = (float)(origin.X / scaling); _cachedOriginY = (float)(origin.Y / scaling); + + // Crosshair screen X — the zero-velocity point used to hold the + // zoom when gaze is lost (PauseForGazeLoss). The crosshair is + // fixed on screen, so recache per frame is cheap and always + // fresh after resizes/geometry changes. + try + { + var vp = new NativeBridge.DasherViewport { struct_size = System.Runtime.InteropServices.Marshal.SizeOf() }; + if (NativeBridge.dasher_get_viewport(_handle, ref vp) == 0 && + NativeBridge.dasher_dasher_to_screen(_handle, vp.crosshair_x, vp.crosshair_y, out var chX, out _) == 0) + { + _cachedCrosshairScreenX = chX; + } + } + catch { } + // Staleness backstop: catches a tracker that stops delivering // entirely (no invalid samples either). CheckGazeStaleness(); diff --git a/src/Dasher.Windows/Engine/NativeBridge.cs b/src/Dasher.Windows/Engine/NativeBridge.cs index 64e6620..56192ca 100644 --- a/src/Dasher.Windows/Engine/NativeBridge.cs +++ b/src/Dasher.Windows/Engine/NativeBridge.cs @@ -231,6 +231,30 @@ public static extern void dasher_frame(IntPtr ctx, long time_ms, public static extern int dasher_import_training_text(IntPtr ctx, [MarshalAs(UnmanagedType.LPUTF8Str)] string text); + // ── Viewport / coordinate conversion (engine screen space) ──────────── + // Used by the gaze-loss hold: the crosshair is the zero-velocity point, + // so parking the pointer on its screen X freezes the zoom WITHOUT + // stopping the engine (no resume click needed — the next valid sample + // simply moves again). + + [StructLayout(LayoutKind.Sequential)] + public struct DasherViewport + { + public int struct_size; + public long crosshair_x; + public long crosshair_y; + public long visible_min_y; + public long visible_max_y; + public int screen_width; + public int screen_height; + } + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int dasher_get_viewport(IntPtr ctx, ref DasherViewport viewport); + + [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)] + public static extern int dasher_dasher_to_screen(IntPtr ctx, long dx, long dy, out int sx, out int sy); + // Absolute path of the current alphabet's user training file — the file // adaptive learning appends to and the UI reads/exports/resets // (DasherCore#84 / #53). Pointer valid until the next engine call. diff --git a/src/Dasher.Windows/EyeGaze/GazeFilter.cs b/src/Dasher.Windows/EyeGaze/GazeFilter.cs index 68abbfe..1a7e392 100644 --- a/src/Dasher.Windows/EyeGaze/GazeFilter.cs +++ b/src/Dasher.Windows/EyeGaze/GazeFilter.cs @@ -13,13 +13,13 @@ namespace Dasher.Windows.EyeGaze; /// /// Three filters, in order: /// -/// 1. DEAD ZONE: if the gaze hasn't moved more than N screen px from the -/// last SENT position, don't send — reduces jitter noise. -/// -/// 2. VELOCITY GATE: if the gaze is moving faster than N px/ms, it's in +/// 1. VELOCITY GATE: if the gaze is moving faster than N px/ms, it's in /// transit (saccade) — hold the last sent position until velocity drops. /// -/// 3. SETTLE WINDOW: the gaze must stay within the dead zone for N ms +/// 2. DEAD ZONE: if the gaze hasn't moved more than N screen px from the +/// last SENT position, don't send — reduces jitter noise. +/// +/// 3. SETTLE WINDOW: the gaze must stay near the candidate for N ms /// before the position is sent — ensures the user has ARRIVED at their /// target, not just passing through. /// @@ -122,9 +122,13 @@ public bool Process(double x, double y, long timestampMs, out double outX, out d return true; } - if (!_hasCandidate || Math.Abs(x - _candidateX) > DeadZonePx || Math.Abs(y - _candidateY) > DeadZonePx) + if (!_hasCandidate) { - // New candidate position — start the settle timer + // New candidate position — start the settle timer. The tolerance + // is its own (NOT the dead zone): a zero dead zone with settle + // on must still allow sub-pixel jitter to complete the dwell — + // reusing DeadZonePx there meant 1px noise restarted the timer + // forever and NOTHING was ever sent (review finding). _candidateX = x; _candidateY = y; _candidateStartMs = timestampMs; @@ -133,6 +137,16 @@ public bool Process(double x, double y, long timestampMs, out double outX, out d return false; } + var settleTol = DeadZonePx > 0 ? DeadZonePx : 2.0; + if (Math.Abs(x - _candidateX) > settleTol || Math.Abs(y - _candidateY) > settleTol) + { + _candidateX = x; + _candidateY = y; + _candidateStartMs = timestampMs; + SettleBlocks++; + return false; + } + // Same candidate — check if settled long enough if (timestampMs - _candidateStartMs >= SettleMs) { diff --git a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs index 0a6de1b..ba1e985 100644 --- a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs +++ b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs @@ -107,6 +107,26 @@ private static string GetTobiiError(int code) public event EventHandler? GazeDataReceived; public event Action? Warning; + + /// + /// Next-log-round diagnostics (the "run as administrator" experiment): + /// resume_device needs elevation on OEM IS5 sensors — logging whether + /// this process IS elevated makes the next eyegaze.log self-interpreting. + /// + private static void LogElevationStatus() + { + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + var elevated = principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + EyeGazeLogger.Log($"Tobii: process elevation = {elevated}"); + } + catch (Exception ex) + { + EyeGazeLogger.Log($"Tobii: elevation check failed: {ex.Message}"); + } + } public string TrackerName => "Tobii (Stream Engine)"; public bool IsConnected { get; private set; } @@ -132,6 +152,7 @@ public Task ConnectAsync() try { EyeGazeLogger.Log("Tobii: ConnectAsync starting"); + LogElevationStatus(); EnsureDllLoaded(); EyeGazeLogger.Log("Tobii: DLL resolved"); diff --git a/tests/Dasher.Windows.Tests/GazeFilterTests.cs b/tests/Dasher.Windows.Tests/GazeFilterTests.cs index 31595c0..8c13f3b 100644 --- a/tests/Dasher.Windows.Tests/GazeFilterTests.cs +++ b/tests/Dasher.Windows.Tests/GazeFilterTests.cs @@ -103,6 +103,37 @@ public void Reset_clears_state_for_a_fresh_session() Assert.True(f.Process(500, 500, T(1000), out _, out _)); } + [Fact] + public void First_sample_is_not_measured_against_the_origin() + { + // Regression (found while writing these tests): the first sample + // after construction/Reset computed velocity from the ORIGIN (0,0) — + // a phantom ~8.8 px/ms saccade for a first gaze at (100,100) that + // velocity-blocked the first real positions after every gaze-loss + // recovery. With the gate ON and settle off, the first sample must + // send immediately. + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 2.0, SettleMs = 0 }; + Assert.True(f.Process(100, 100, T(0), out _, out _)); + Assert.Equal(0, f.LastVelocity, precision: 6); + // And after Reset (every gaze-loss recovery): + f.Reset(); + Assert.True(f.Process(400, 300, T(1000), out _, out _)); + } + + [Fact] + public void Zero_dead_zone_with_settle_still_completes_the_dwell() + { + // Regression (review finding): the settle candidate check reused + // DeadZonePx as its tolerance — with the dead zone set to 0 (the + // settings slider's minimum), 1px jitter restarted the dwell timer + // forever and NOTHING was ever sent. + var f = new GazeFilter { DeadZonePx = 0, MaxVelocityPxPerMs = 0, SettleMs = 50 }; + Assert.False(f.Process(100, 100, T(0), out _, out _)); // candidate + Assert.False(f.Process(101, 100, T(20), out _, out _)); // 1px jitter + Assert.False(f.Process(100, 101, T(40), out _, out _)); // more jitter + Assert.True(f.Process(101, 100, T(60), out _, out _)); // dwell completed despite jitter + } + [Fact] public void Velocity_gate_cancels_pending_settle() { From 4da6adab154a233b585b121a70100fbffd16b79c Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 30 Oct 2026 21:15:39 +0000 Subject: [PATCH 4/5] fix(eyegaze): true two-axis freeze - park on crosshair X AND Y 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 --- .../Controls/DasherCanvas.EyeGaze.cs | 133 ++++++++++++++---- src/Dasher.Windows/Controls/DasherCanvas.cs | 26 ++-- src/Dasher.Windows/Controls/SettingsPanel.cs | 1 - src/Dasher.Windows/Engine/NativeBridge.cs | 6 +- 4 files changed, 120 insertions(+), 46 deletions(-) diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index 8fad835..5f9aa1e 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -14,6 +14,15 @@ public partial class DasherCanvas private long _lastGazeTicks; private static readonly TimeSpan GazeTimeout = TimeSpan.FromMilliseconds(500); + /// True while the engine is parked on the crosshair because + /// gaze was lost; the next valid sample clears it. + private volatile bool _gazePausedForLoss; + + /// Serialises GazeFilter use between the gaze callback + /// thread (Process) and the UI thread (Reset in PauseForGazeLoss, + /// ConfigureGazeFilter swaps). + private readonly object _gazeFilterLock = new(); + public bool IsEyeTracking => _useEyeGazeInput; public bool IsEyeTrackActive => _eyeTrackActive; @@ -46,6 +55,11 @@ public async Task InitializeEyeGazeAsync(EyeGazeIntegration.TrackerType tr _eyeGazeIntegration.Warning += OnEyeTrackerWarning; _gazePausedForLoss = false; _lastGazeTicks = 0; + _crosshairCached = false; + _crosshairQueryFailedLogged = false; + // Cache the crosshair now (not first-Render) so the + // hold-before-first-paint window is empty. + CacheCrosshairScreenPosition(); EyeGazeLogger.Log("Eye gaze input active — indicator should appear (green/red dot top-right)"); } return ok; @@ -102,18 +116,21 @@ private void DisableEyeGaze(bool blocking) /// public void ConfigureGazeFilter(double? deadZonePx = null, double? maxVelocity = null, double? settleMs = null) { - if (deadZonePx == null && maxVelocity == null && settleMs == null) + lock (_gazeFilterLock) { - _gazeFilter = null; // disable - EyeGazeLogger.Log("Gaze filter DISABLED (raw passthrough)"); - return; - } + if (deadZonePx == null && maxVelocity == null && settleMs == null) + { + _gazeFilter = null; // disable + EyeGazeLogger.Log("Gaze filter DISABLED (raw passthrough)"); + return; + } - _gazeFilter ??= new GazeFilter(); - if (deadZonePx.HasValue) _gazeFilter.DeadZonePx = deadZonePx.Value; - if (maxVelocity.HasValue) _gazeFilter.MaxVelocityPxPerMs = maxVelocity.Value; - if (settleMs.HasValue) _gazeFilter.SettleMs = settleMs.Value; - EyeGazeLogger.Log($"Gaze filter: deadZone={_gazeFilter.DeadZonePx}px vel={_gazeFilter.MaxVelocityPxPerMs}px/ms settle={_gazeFilter.SettleMs}ms"); + _gazeFilter ??= new GazeFilter(); + if (deadZonePx.HasValue) _gazeFilter.DeadZonePx = deadZonePx.Value; + if (maxVelocity.HasValue) _gazeFilter.MaxVelocityPxPerMs = maxVelocity.Value; + if (settleMs.HasValue) _gazeFilter.SettleMs = settleMs.Value; + EyeGazeLogger.Log($"Gaze filter: deadZone={_gazeFilter.DeadZonePx}px vel={_gazeFilter.MaxVelocityPxPerMs}px/ms settle={_gazeFilter.SettleMs}ms"); + } } private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) @@ -122,10 +139,9 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) if (!gazePoint.IsValid) { - // Tracker says eyes not found: pause immediately (the same - // stop-outside idiom as the pointer leaving the canvas). - // Dropping invalid samples silently left the last gaze - // position driving the zoom (the eyegaze.log field session). + // Tracker says eyes not found: hold immediately. Dropping + // invalid samples silently left the last gaze position + // driving the zoom (the eyegaze.log field session). PauseForGazeLoss("invalid sample"); return; } @@ -146,7 +162,6 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) x = (float)(gazePoint.X - _cachedOriginX); y = (float)(gazePoint.Y - _cachedOriginY); } - _lastGazeY = y; // Apply the gaze filter (velocity gate → dead zone → settle). // Local capture: ConfigureGazeFilter runs on the UI thread and @@ -155,7 +170,10 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) if (filter != null) { var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - if (!filter.Process(x, y, nowMs, out var filteredX, out var filteredY)) + double filteredX = x, filteredY = y; + bool send; + lock (_gazeFilterLock) send = filter.Process(x, y, nowMs, out filteredX, out filteredY); + if (!send) return; // filtered out (in transit, jitter, or not settled) // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz) @@ -174,23 +192,69 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) NativeBridge.dasher_mouse_move(_handle, x, y); } - /// True while the engine is parked on the crosshair because - /// gaze was lost; the next valid sample clears it. - private volatile bool _gazePausedForLoss; - - /// Last valid gaze Y in canvas-local DIPs (the hold keeps - /// it so the freeze doesn't jump the Y axis). - private float _lastGazeY; - /// Screen X of the crosshair in engine screen space — the /// zero-velocity point. Cached by Render (UI thread); the hold reads /// it from the gaze callback thread. private volatile float _cachedCrosshairScreenX; + /// + /// Screen Y of the crosshair. BOTH axes matter: parking on the X + /// alone freezes the zoom but keeps steering the vertical pan (the + /// engine pans toward the pointer's Y at a constant rate — the + /// root-independent screen→Dasher mapping never self-corrects). The + /// full (X, Y) park is a TRUE freeze: dasher (2048, 2048) yields + /// zero zoom AND zero pan. + /// + /// Geometry assumption: valid for the default/squish geometries + /// (integer-exact screen↔Dasher round-trip). NOT maintained for + /// LP_GEOMETRY=square_no_xhair (crosshair offscreen + reverse + /// boost): the hold would reverse-zoom. Gaze users don't reach that + /// advanced engine setting via the Dasher UI. + /// + private volatile float _cachedCrosshairScreenY; + private volatile bool _crosshairCached; + private bool _crosshairQueryFailedLogged; + + /// + /// Query the engine for the crosshair's screen position. UI thread + /// (Render) or once at gaze init; retried per frame until it + /// succeeds so the uncached window is empty. + /// + private void CacheCrosshairScreenPosition() + { + try + { + var vp = new NativeBridge.DasherViewport + { + struct_size = System.Runtime.InteropServices.Marshal.SizeOf() + }; + if (NativeBridge.dasher_get_viewport(_handle, ref vp) == 0 && + NativeBridge.dasher_dasher_to_screen(_handle, vp.crosshair_x, vp.crosshair_y, out var chX, out var chY) == 0) + { + _cachedCrosshairScreenX = chX; + _cachedCrosshairScreenY = chY; + _crosshairCached = true; + } + else if (!_crosshairQueryFailedLogged) + { + _crosshairQueryFailedLogged = true; + EyeGazeLogger.Log("Gaze hold: viewport query failed — will retry each frame (park unavailable until it succeeds)"); + } + } + catch (Exception ex) + { + if (!_crosshairQueryFailedLogged) + { + _crosshairQueryFailedLogged = true; + EyeGazeLogger.Log($"Gaze hold: viewport query threw: {ex.Message} — will retry each frame"); + } + } + } + /// /// Park the pointer on the crosshair: the crosshair is the engine's - /// zero-velocity point, so the zoom freezes WITHOUT stopping the - /// engine — no resume click is needed and the next valid sample + /// zero-velocity point, so the zoom AND pan freeze WITHOUT stopping + /// the engine — no resume click is needed and the next valid sample /// simply moves again. (The stop-outside idiom was rejected in /// review: BP_STOP_OUTSIDE performs a full engine stop that needs a /// Primary_Input click to resume — a click gaze users can't make; @@ -200,14 +264,25 @@ private void PauseForGazeLoss(string reason) { _eyeTrackActive = false; var filter = _gazeFilter; - filter?.Reset(); + lock (_gazeFilterLock) filter?.Reset(); if (_gazePausedForLoss) return; // already holding _gazePausedForLoss = true; + if (!_crosshairCached) + { + // Not cached yet (gaze lost before the first Render): retry + // now. If it still fails, skip the park this instant — the + // next Render caches and subsequent losses hold properly. + CacheCrosshairScreenPosition(); + if (!_crosshairCached) + { + EyeGazeLogger.Log($"Gaze lost ({reason}) — crosshair not cached yet, holding skipped this once"); + return; + } + } EyeGazeLogger.Log($"Gaze lost ({reason}) — holding on crosshair"); try { - var holdX = _cachedCrosshairScreenX != 0 ? _cachedCrosshairScreenX : (float)(Bounds.Width / 2); - NativeBridge.dasher_mouse_move(_handle, holdX, _lastGazeY); + NativeBridge.dasher_mouse_move(_handle, _cachedCrosshairScreenX, _cachedCrosshairScreenY); } catch (Exception ex) { diff --git a/src/Dasher.Windows/Controls/DasherCanvas.cs b/src/Dasher.Windows/Controls/DasherCanvas.cs index d9776dc..86f0cfa 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.cs @@ -234,20 +234,13 @@ public override void Render(DrawingContext context) _cachedOriginX = (float)(origin.X / scaling); _cachedOriginY = (float)(origin.Y / scaling); - // Crosshair screen X — the zero-velocity point used to hold the - // zoom when gaze is lost (PauseForGazeLoss). The crosshair is - // fixed on screen, so recache per frame is cheap and always - // fresh after resizes/geometry changes. - try - { - var vp = new NativeBridge.DasherViewport { struct_size = System.Runtime.InteropServices.Marshal.SizeOf() }; - if (NativeBridge.dasher_get_viewport(_handle, ref vp) == 0 && - NativeBridge.dasher_dasher_to_screen(_handle, vp.crosshair_x, vp.crosshair_y, out var chX, out _) == 0) - { - _cachedCrosshairScreenX = chX; - } - } - catch { } + // Crosshair screen position — the zero-velocity point used to + // hold the zoom when gaze is lost (PauseForGazeLoss). Zoom- + // invariant but RESIZE-dependent: TrySetScreenSize invalidates + // the cache when the canvas dims change; until then the cached + // point is reused (2 P/Invokes saved per frame). + if (!_crosshairCached) + CacheCrosshairScreenPosition(); // Staleness backstop: catches a tracker that stops delivering // entirely (no invalid samples either). @@ -402,6 +395,11 @@ private void TrySetScreenSize() NativeBridge.dasher_set_screen_size(_handle, w, h); _lastScreenWidth = w; _lastScreenHeight = h; + // The crosshair's screen position (the gaze hold's zero-velocity + // point) is resize-dependent — invalidate so the next Render + // recaches it (review loop 3: a stale park silently reintroduced + // the pan-during-blink bug after any resize/DPI change). + _crosshairCached = false; } private void Tick(long timeMs) diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index aa6658a..3f282bc 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -638,7 +638,6 @@ void PopulateSelections(AccessMethod method) IsChecked = config.GazeFilterEnabled, OnContent = "", OffContent = "", MinWidth = 0, Height = 24, }; - var filterToggleRow = BuildToggleRow("Filter gaze", config.GazeFilterEnabled, null); var filterToggleLabel = new TextBlock { Text = "Filter gaze (reduces zoom toward wrong positions during eye transit)", diff --git a/src/Dasher.Windows/Engine/NativeBridge.cs b/src/Dasher.Windows/Engine/NativeBridge.cs index 56192ca..cc8da0d 100644 --- a/src/Dasher.Windows/Engine/NativeBridge.cs +++ b/src/Dasher.Windows/Engine/NativeBridge.cs @@ -233,9 +233,11 @@ public static extern int dasher_import_training_text(IntPtr ctx, // ── Viewport / coordinate conversion (engine screen space) ──────────── // Used by the gaze-loss hold: the crosshair is the zero-velocity point, - // so parking the pointer on its screen X freezes the zoom WITHOUT + // so parking the pointer on its screen position freezes the zoom WITHOUT // stopping the engine (no resume click needed — the next valid sample - // simply moves again). + // simply moves again). NOTE: dasher.h marks these as testing-oriented + // APIs that may change between engine versions — the pinned submodule + + // struct_size versioning keep that safe here. [StructLayout(LayoutKind.Sequential)] public struct DasherViewport From f3926fe353f18dc9c7563607a8dab468959a21b1 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 30 Oct 2026 22:09:56 +0000 Subject: [PATCH 5/5] feat(eyegaze): diagnostics toggle - detailed logging off by default 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 --- .../Controls/DasherCanvas.EyeGaze.cs | 13 ++++--- src/Dasher.Windows/Controls/SettingsPanel.cs | 38 +++++++++++++++++++ .../Engine/AccessConfiguration.cs | 9 +++++ src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs | 11 ++++++ .../EyeGaze/TobiiStreamEngineTracker.cs | 19 ++++++---- src/Dasher.Windows/Views/MainWindow.axaml.cs | 4 ++ 6 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs index 5f9aa1e..5b7cfe8 100644 --- a/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs +++ b/src/Dasher.Windows/Controls/DasherCanvas.EyeGaze.cs @@ -177,12 +177,15 @@ private void OnEyeGazePositionChanged(object? sender, GazePoint gazePoint) return; // filtered out (in transit, jitter, or not settled) // Diagnostic: log every 30th sample (≈once per 500ms at 60Hz) - _gazeLogInterval++; - if (_gazeLogInterval >= 30) + if (EyeGazeLogger.Verbose) { - _gazeLogInterval = 0; - EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={filter.LastVelocity:F1}px/ms " + - $"[total={filter.TotalSamples} sent={filter.SentSamples} dz={filter.DeadZoneBlocks} vel_blk={filter.VelocityBlocks} settle_blk={filter.SettleBlocks}]"); + _gazeLogInterval++; + if (_gazeLogInterval >= 30) + { + _gazeLogInterval = 0; + EyeGazeLogger.Log($"gaze filter: sent=({filteredX:F0},{filteredY:F0}) vel={filter.LastVelocity:F1}px/ms " + + $"[total={filter.TotalSamples} sent={filter.SentSamples} dz={filter.DeadZoneBlocks} vel_blk={filter.VelocityBlocks} settle_blk={filter.SettleBlocks}]"); + } } x = (float)filteredX; diff --git a/src/Dasher.Windows/Controls/SettingsPanel.cs b/src/Dasher.Windows/Controls/SettingsPanel.cs index 3f282bc..7465f37 100644 --- a/src/Dasher.Windows/Controls/SettingsPanel.cs +++ b/src/Dasher.Windows/Controls/SettingsPanel.cs @@ -657,6 +657,40 @@ void PopulateSelections(AccessMethod method) TextWrapping = TextWrapping.Wrap, }; + // Eye-tracker diagnostics toggle: detailed per-sample logging to + // %APPDATA%\Dasher\eyegaze.log (off by default; lifecycle events + // always logged). The file to attach to bug reports. + var diagToggle = new ToggleSwitch + { + IsChecked = config.GazeDiagnostics, + OnContent = "", OffContent = "", MinWidth = 0, Height = 24, + }; + var diagToggleLabel = new TextBlock + { + Text = "Save eye-tracker diagnostics", + FontSize = 12, + Foreground = BrushValue, + VerticalAlignment = VerticalAlignment.Center, + }; + var diagTogglePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + diagTogglePanel.Children.Add(diagToggle); + diagTogglePanel.Children.Add(diagToggleLabel); + var diagHelp = new TextBlock + { + Text = "Detailed tracking log for bug reports (per-sample lines, written to eyegaze.log in the Dasher folder). Basic connection events are always logged.", + FontSize = 11, + Foreground = BrushMuted, + TextWrapping = TextWrapping.Wrap, + }; + diagToggle.PropertyChanged += (s, e) => + { + if (e.Property.Name != "IsChecked") return; + var cfg = AccessConfiguration.Load(); + cfg.GazeDiagnostics = diagToggle.IsChecked == true; + cfg.Save(); + EyeGaze.EyeGazeLogger.Verbose = cfg.GazeDiagnostics; + }; + var deadZoneSlider = new Slider { Minimum = 0, Maximum = 30, Value = config.GazeDeadZonePx, @@ -724,6 +758,8 @@ void UpdateTrackerVisibility() filterHeader.IsVisible = isEyeGaze; filterTogglePanel.IsVisible = isEyeGaze; gazeFilterHelp.IsVisible = isEyeGaze; + diagTogglePanel.IsVisible = isEyeGaze; + diagHelp.IsVisible = isEyeGaze; deadZoneSlider.IsVisible = isEyeGaze; deadZoneLabel.IsVisible = isEyeGaze; settleSlider.IsVisible = isEyeGaze; @@ -906,6 +942,8 @@ void UpdateTrackerVisibility() panel.Children.Add(filterHeader); panel.Children.Add(filterTogglePanel); panel.Children.Add(gazeFilterHelp); + panel.Children.Add(diagTogglePanel); + panel.Children.Add(diagHelp); panel.Children.Add(deadZoneSlider); panel.Children.Add(deadZoneLabel); panel.Children.Add(settleSlider); diff --git a/src/Dasher.Windows/Engine/AccessConfiguration.cs b/src/Dasher.Windows/Engine/AccessConfiguration.cs index 7a96883..15b0ff0 100644 --- a/src/Dasher.Windows/Engine/AccessConfiguration.cs +++ b/src/Dasher.Windows/Engine/AccessConfiguration.cs @@ -25,6 +25,15 @@ public class AccessConfiguration /// Master switch: false = raw passthrough (pre-filter behaviour). public bool GazeFilterEnabled { get; set; } = true; + /// + /// Detailed eye-tracker diagnostics (per-sample flow lines, raw struct + /// dumps, filter stats) written to %APPDATA%\Dasher\eyegaze.log. The + /// always-on layer is lifecycle events only (connect, resume, heartbeat + /// counters, gaze lost/resumed, warnings). Default off; turn on when + /// reporting tracker problems. + /// + public bool GazeDiagnostics { get; set; } + /// /// The engine input filter (SP_INPUT_FILTER). MIRROR ONLY — the engine's /// own persistence (dasher_settings.xml, DasherCore #76 / v0.2.16+) owns diff --git a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs index 8ebed10..e8f4e78 100644 --- a/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs +++ b/src/Dasher.Windows/EyeGaze/EyeGazeLogger.cs @@ -11,6 +11,16 @@ public static class EyeGazeLogger Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Dasher", "eyegaze.log"); + /// + /// Detailed diagnostics (per-sample flow lines, heartbeat detail, raw + /// struct dumps, gaze filter stats). Off by default — the always-on + /// layer is lifecycle events only (connect, resume, warnings, gaze + /// lost/resumed), which is low-volume and exactly what field triage + /// needs first. Users reporting tracker problems turn this on in + /// Settings > Input ("Save eye-tracker diagnostics"). + /// + public static bool Verbose; + private static long _gazeLogCounter; private const long GazeLogInterval = 60; // log every 60th sample (~2s at 33Hz) @@ -40,6 +50,7 @@ public static void Log(string message) public static void LogGazeData(float x, float y, bool valid) { + if (!Verbose) return; var count = Interlocked.Increment(ref _gazeLogCounter); if (count == 1) Log($"First gaze data received: x={x:F1} y={y:F1} valid={valid}"); diff --git a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs index ba1e985..6e8a5cb 100644 --- a/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs +++ b/src/Dasher.Windows/EyeGaze/TobiiStreamEngineTracker.cs @@ -471,11 +471,13 @@ private void ProcessCallbacks(CancellationToken ct) continue; } - // Heartbeat every 5 seconds + // Heartbeat every 5 seconds — the valid/invalid counters are + // the field-triage signal (degradation = rate ceiling + + // validity collapse), so this rides the ALWAYS-ON layer. var now = DateTime.UtcNow; if (now - lastHeartbeat >= TimeSpan.FromSeconds(5)) { - EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid"); + EyeGazeLogger.Log($"Tobii: Heartbeat — {sampleCount} callbacks processed, {_validCount} valid, {_invalidCount} invalid{(EyeGazeLogger.Verbose ? "" : " (diagnostics off — Settings > Input to enable per-sample lines)")}"); lastHeartbeat = now; } } @@ -498,12 +500,15 @@ private void ProcessCallbacks(CancellationToken ct) private void OnGazePoint(ref tobii_gaze_point_t gaze_point) { - // Dump raw struct bytes for the first few samples to verify layout - var dumpIdx = Interlocked.Increment(ref _rawDumpCount); - if (dumpIdx <= MaxRawDumps) + // Raw struct dumps (diagnostics only — layout verification) + if (EyeGazeLogger.Verbose) { - EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}"); - EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}"); + var dumpIdx = Interlocked.Increment(ref _rawDumpCount); + if (dumpIdx <= MaxRawDumps) + { + EyeGazeLogger.Log($"Tobii: RAW gaze #{dumpIdx}: ts={gaze_point.timestamp_us} validity={gaze_point.validity} x={gaze_point.x:F6} y={gaze_point.y:F6}"); + EyeGazeLogger.Log($" raw floats: ts_hex={BitConverter.DoubleToInt64Bits(gaze_point.timestamp_us):X16} x_bits={BitConverter.SingleToInt32Bits(gaze_point.x):X8} y_bits={BitConverter.SingleToInt32Bits(gaze_point.y):X8}"); + } } if (gaze_point.validity == 0) diff --git a/src/Dasher.Windows/Views/MainWindow.axaml.cs b/src/Dasher.Windows/Views/MainWindow.axaml.cs index 7594b6d..4d81b98 100644 --- a/src/Dasher.Windows/Views/MainWindow.axaml.cs +++ b/src/Dasher.Windows/Views/MainWindow.axaml.cs @@ -714,6 +714,10 @@ private async void RestoreEyeGazeFromConfig() if (_canvas == null) return; _canvas.DisableJoystick(); + // Detailed eye-tracker diagnostics (per-sample lines, raw dumps, + // filter stats). Lifecycle logging is always on. + EyeGaze.EyeGazeLogger.Verbose = config.GazeDiagnostics; + // Apply the gaze filter configuration (transit zoom fix) if (config.GazeFilterEnabled) {