678: Port iOS's PID gaze smoothing and switch ARCore tracking to position-based - #680
Merged
Conversation
…ID controller Ports Pulse's full algorithm (P+I+D terms, per-tick integral damping, quiescence/deadband detection, dt capping/chunking) rather than just its gain constants, and wires it in as the default production smoothing in FaceTrackingViewModel, replacing Vector3.lerp with no debug toggle required. Closes #678
…S semantics On-device iteration against iOS on a Pixel 3a surfaced and fixed, in order: - Tick on Choreographer vsync (CADisplayLink equivalent) toward the latest raw target instead of once per ~30fps ARCore frame; a delay()-loop version fed timing jitter into the dt-dividing PID terms (visible bounce before settling) - Apply the !isTablet y*2 reachability scaling after smoothing, not before - pre-scaling doubled y's noise floor and made y alone drift at rest - Wake hysteresis + per-sample wake confirmation so boundary noise can neither flicker quiescence nor accumulate into at-rest drift, with dt-chunked frame hitches unable to satisfy the confirmation from one sample - Leaky freeze at quiescence so a sub-wake settling residual oozes in over ~1/3s instead of correcting in one visible snap once it crosses the wake threshold - Reset the filter on face-tracking loss, matching iOS needsResetOnNextUpdate - Skip StateFlow emission on frozen ticks (Vector3 lacks equals; 60Hz identical emissions recomposed the cursor at rest) - Restore iOS's literal minimumValueStep=0.010 - the earlier rescale was compensating for the two bugs above, confirmed by raw-signal capture - Sensitivity setting now maps to a cursor-travel amplitude multiplier (0.75/1.0/1.3, mirroring CursorSensitivity.swift ratios) applied after smoothing, matching what sensitivity means on iOS; it was orphaned when the lerp (whose blend fraction it fed) was removed Part of #678 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/FaceLandmarker) Live A/B tooling for completing #678's on-device assessment: a BuildConfig.DEBUG-gated selector in Timing & Sensitivity switches which tracking source feeds the PID smoothing, isolating the engine as the only variable. Ported from the #629 spike branch with artifact-level isolation: - MediaPipe/CameraX are debugImplementation; trackers, comparison screens, and model assets live in app/src/debug - verified zero MediaPipe/CameraX entries on releaseRuntimeClasspath - MainActivity reaches the debug screens through a source-set-split composable (real host in src/debug, empty stub in src/release) - FaceTrackingViewModel takes engine input via an engine-agnostic onDebugEngineUpdate(x, y); per-engine remap constants live with each debug adapter, y-amplitudes halved to account for the tick loop's phone y*2 scaling the spike's paths never had - Engine switch resets the PID filter and raw target (signals aren't in a shared coordinate space), and stale frames from a tracker being torn down mid-switch are ignored Part of #678 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pture The engine comparison isolated a yaw-to-pitch cross-error in ARCore's face orientation estimate: the cursor swooped vertically during horizontal turns, and the artifact survived centerPose, angle decomposition, velocity gating, and a faithful port of iOS's camera-relative ray projection - placing it in the orientation estimate itself (RGB mesh fit vs iPhone's TrueDepth). Directly observed positions don't have it, so the cursor now tracks the nose-tip's position in the camera's display-oriented frame, depth-normalized, relative to a neutral averaged over the first ~0.7s of tracking. Also: per-sample processing runs inline instead of a launch-and-skip-if-busy background job that silently dropped camera frames, and the session requests a 60fps camera config where the hardware offers one (guarded fallback to the default). Part of #678 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The debug-only ARCore/FaceDetector/FaceLandmarker comparison served its purpose - ARCore with position-based tracking was selected - so the MediaPipe dependencies, model assets, debug source set, engine plumbing, and settings selector come out for a reviewable diff against main. The full tooling remains intact at 9dd1bd6; a future engine evaluation should revert this commit rather than rebuild it. Part of #678 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getSupportedCameraConfigs() entries vary by more than fps (capture resolution, GPU texture size) and list order is not contractual - taking first() could change resolution as a side effect of requesting 60fps, affecting face-mesh quality and CPU load. Prefer the config whose image size matches what the session already chose. Review finding from the pre-PR #678 review round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enable reset; make FaceTrackingViewModel testable Correctness fixes from the pre-PR review round: - Wake confirmation now counts DISTINCT samples, not vsync ticks. The tick loop re-presents the same raw sample across ~2 frames at 60Hz/30fps and ~4 at 120Hz/30fps, so counting calls let one noisy sample satisfy wakeConfirmationTicks=3 on a high-refresh display - the exact at-rest drift the confirmation exists to prevent. PIDFilter.filter() takes isNewSample; the tick loop derives it from reference identity (every sample is a fresh GazePoint instance). - Filter timestamps are nanoseconds (frameTimeNanos passthrough). Millisecond truncation put +/-6% dt jitter into the dt-dividing terms at 120Hz - the noise class the Choreographer move fixed. - Disable->re-enable of head tracking resets filter/neutral/target. Re-enabling composes a new ARCore session; the old neutral is a stale camera-frame quantity, and nothing else cleared it, leaving the cursor off-center until a >=1s tracking loss. - Tick loop stops itself when there is no target or tracking is off, instead of waking the main thread at vsync for the ViewModel's lifetime (Pulse pauses its CADisplayLink for the same reason). - Threading comments corrected: sceneview 2.3.3 delivers session updates from a main-thread Choreographer callback (verified in its sources) - there is no background writer. Main-thread confinement is documented as the load-bearing invariant; @volatile dropped. Testability refactor, closing the repo-baseline coverage gap: - Constructor injection (prefs interface, isTablet, accessibility check, FrameClock) replaces KoinComponent/inject/get<Context>(). - Neutral calibration + position mapping extracted to pure HeadPositionTracker; vsync abstracted behind FrameClock. - GazePoint (value equality) replaces Vector3 in the smoothing path, so StateFlow dedups frozen-output ticks natively - manual last-emitted tracking and the constant-zero z filter are deleted; ad hoc backgroundScope replaced by viewModelScope. - New tests: HeadPositionTrackerTest (6), FaceTrackingViewModelTest (7), and PIDFilterTest cases pinning the +Kd momentum sign, the 1s catch-up cap, and stale-sample wake counting (16 total). Deliberately unchanged, confirmed as Pulse/iOS parity: +Kd momentum D-term sign, minimumValueStep=0.010, post-filter y*2 scaling, and per-tick integral damping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the review findings and fixes, what was deliberately left as Pulse/iOS parity, and adds high-refresh-rate displays to the known verification gaps (per-tick integral damping decays faster there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Position-based tracking + PID pipeline replaces the lerp description; FaceTrackingViewModel is no longer in the untested-ViewModels list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Michael Gonzalez (MicGon7)
requested review from
Mansimran Singh (Mansimran-Singh) and
dlucci-wt
as code owners
August 14, 2026 15:58
6 tasks
…line The baseline CLAUDE.md pointed at this file but it was never created or committed. Recreated current as of #678: user journey, tech stack, and the gaze-cursor pipeline (position-based tracking + PID smoothing). All three Mermaid blocks validated against the Mermaid renderer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mansimran Singh (Mansimran-Singh)
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
📹 Demo video:
vocable_android_pid.mp4
What position-based tracking changes for users (product should ack)
Key decisions (full history:
Documentation/work-log/678-pid-gaze-smoothing.md)9dd1bd65if we ever revisit engines.976012ea/d862fa40/56ac628c: a high-refresh-display path back to the at-rest drift bug, stale calibration surviving a head-tracking off/on toggle, timing precision, a tick loop that never stopped, and a refactor makingFaceTrackingViewModelunit-testable for the first time.Ticket
Part of #629 — closes #678
Type of Change
Testing
Checklist
./gradlew testDebug)