|
| 1 | +# Back-Face Hit Rejection — High-Level Design |
| 2 | + |
| 3 | +**Created**: 2026-04-16 |
| 4 | +**Feature area**: Reflection artifact rejection in libsurvive tracking pipeline |
| 5 | + |
| 6 | +## Problem Statement |
| 7 | + |
| 8 | +Lighthouse sweep light reflects off hard surfaces (LED walls, monitor bezels, glass, shiny |
| 9 | +floors) and arrives at tracker sensors from the wrong direction. These reflected hits are |
| 10 | +geometrically consistent — they trace a valid optical path — so libsurvive's existing |
| 11 | +Chauvenet outlier filter and sensor variance gates do not reject them. The Kalman filter |
| 12 | +accepts them, its internal state drifts, and the tracker emits bad poses ("wild |
| 13 | +spinning/freezing") before eventually recovering. |
| 14 | + |
| 15 | +The existing stagehand defenses fire too late: |
| 16 | + |
| 17 | +- **Python reflection filter** (`shtp_receiver.py`): gates bad *output* after the Kalman |
| 18 | + state is already corrupted. Recovery takes several seconds. |
| 19 | +- **Per-LH innovation gate** (`--light-outlier-threshold 5.0`): catches |
| 20 | + moderate-to-heavy reflection bursts (rms > 0.001) but cannot distinguish mild |
| 21 | + reflections (rms 0.0001–0.0002) from legitimate noise — both are within 2× of the |
| 22 | + noise floor. |
| 23 | +- **Angular rate gate** (`--lc-angular-rate-max`): Kalman-level input velocity gate. |
| 24 | + Once mild reflections corrupt state, the gate defends the *wrong* state and locks out |
| 25 | + valid data. |
| 26 | + |
| 27 | +None of these gates can catch reflections that are geometrically correct but physically |
| 28 | +impossible given the tracker's current orientation. Only a geometry-based filter can. |
| 29 | + |
| 30 | +## Goals |
| 31 | + |
| 32 | +1. Prevent reflected lighthouse hits from reaching the Kalman filter by rejecting them at |
| 33 | + the angle-measurement level. |
| 34 | +2. Eliminate the "spinning/freeze" failure mode caused by mild reflections that slip |
| 35 | + through the innovation gate. |
| 36 | +3. Zero operator configuration — no manual per-environment threshold tuning beyond the |
| 37 | + initial deployment value. |
| 38 | +4. No regression on clean tracking (healthy room, no reflective surfaces). |
| 39 | + |
| 40 | +## Target Users |
| 41 | + |
| 42 | +Virtual production operators deploying stagehand in studio environments with LED walls, |
| 43 | +monitor arrays, or reflective surfaces. Operators are not sysadmins — any fix requiring |
| 44 | +environment-specific tuning is a design failure. |
| 45 | + |
| 46 | +## System Architecture |
| 47 | + |
| 48 | +The back-face filter sits at the earliest possible interception point in the libsurvive |
| 49 | +pipeline: `SurviveSensorActivations_check_outlier()` in `survive_sensor_activations.c`, |
| 50 | +called before any angle measurement reaches the Kalman tracker or geometric posers. |
| 51 | + |
| 52 | +``` |
| 53 | +USB hardware |
| 54 | + │ raw photon timestamps |
| 55 | + ▼ |
| 56 | +driver_vive (USB driver) |
| 57 | + │ LightcapElement |
| 58 | + ▼ |
| 59 | +survive_process_gen1/2 |
| 60 | + │ decoded angle measurements |
| 61 | + ▼ |
| 62 | +SurviveSensorActivations_check_outlier() ← BACK-FACE FILTER FIRES HERE |
| 63 | + │ (reject if facingness < 0.1) |
| 64 | + │ (skip if pose confidence < 0.1) |
| 65 | + │ (skip if LH not yet calibrated) |
| 66 | + ▼ |
| 67 | +survive_kalman_tracker_integrate_saved_light() |
| 68 | + │ per-LH innovation gate (light-outlier-threshold) |
| 69 | + ▼ |
| 70 | +Kalman measurement update (IEKF) |
| 71 | + ▼ |
| 72 | +survive_kalman_tracker_report_state() |
| 73 | + │ angular rate gate (lc-angular-rate-max) |
| 74 | + ▼ |
| 75 | +pose hook → stagehand agent → SHTP → Python reflection filter |
| 76 | +``` |
| 77 | + |
| 78 | +The filter is **already fully implemented** in the libsurvive fork as |
| 79 | +`--filter-normal-facingness`. It is currently disabled in stagehand with |
| 80 | +`--filter-normal-facingness -1`. This design activates it. |
| 81 | + |
| 82 | +### Filter Geometry |
| 83 | + |
| 84 | +For each incoming sensor hit `(sensor_id, lh, axis)`: |
| 85 | + |
| 86 | +``` |
| 87 | +normalInWorld = quatrotatevector(OutPose.Rot, sensor_normals[sensor_id]) |
| 88 | +sensorInWorld = ApplyPoseToPoint(OutPose, sensor_locations[sensor_id]) |
| 89 | +towardLH = normalize(bsd[lh].Pose.Pos - sensorInWorld) |
| 90 | +facingness = dot(normalInWorld, towardLH) |
| 91 | +
|
| 92 | +if facingness < threshold: → reject (physically impossible hit) |
| 93 | +``` |
| 94 | + |
| 95 | +A sensor with its face pointing away from the lighthouse cannot receive direct lighthouse |
| 96 | +light. A reflection arriving from behind cannot be a real hit. Threshold = 0.1 rejects |
| 97 | +sensors pointing more than ~84° away from the lighthouse — the last ~6° near the geometric |
| 98 | +horizon where TS4231 sensitivity is negligible in practice. |
| 99 | + |
| 100 | +## Key Design Decisions |
| 101 | + |
| 102 | +| Decision | Choice | Rationale | |
| 103 | +|---|---|---| |
| 104 | +| Enable existing impl vs. reimplement | Enable existing (`--filter-normal-facingness`) | Zero new code; implementation is correct, documented, and has property tests. Reimplementing at `angle_process_func` hook would duplicate logic with no benefit. | |
| 105 | +| Threshold | `0.1` (~84° acceptance cone) | Preserves all physically plausible hits; rejects TS4231 edge zone (near-90°) where sensitivity degrades anyway. Geometrically motivated — not empirically tuned. | |
| 106 | +| Relationship to existing gates | Layer (keep all gates) | Defense-in-depth. Back-face filter prevents what the innovation gate cannot see. Innovation gate catches non-geometry anomalies (calibration errors, multipath scatter). Both run. Angular rate gate and Python reflection filter remain as downstream backstops. | |
| 107 | +| Confidence guard threshold | `0.1` (existing default) | Prevents the filter from firing during cold-start before pose has converged — ensures filter activates only when it has a reliable world-frame normal to compare against. | |
| 108 | + |
| 109 | +## Non-Goals |
| 110 | + |
| 111 | +- Replacing the per-LH innovation gate or angular rate gate — these catch different failure |
| 112 | + modes. |
| 113 | +- Eliminating the Python reflection filter — it handles application-layer burst detection. |
| 114 | +- Per-environment threshold tuning — threshold 0.1 is fixed for stagehand deployments. |
| 115 | +- Handling multipath (multiple-bounce reflections from a front-facing direction) — |
| 116 | + geometry cannot detect these; the innovation gate handles them. |
| 117 | + |
| 118 | +## References |
| 119 | + |
| 120 | +- [docs/reflection-rejection.md](reflection-rejection.md) — full implementation details |
| 121 | + (Change 1) |
| 122 | +- `src/survive_sensor_activations.c` — filter implementation |
| 123 | +- `src/test_cases/normal_filter_props.c` — 6 property tests for the filter geometry |
| 124 | +- `stagehand/scripts/stagehand-health` — deployment change (one line: `-1` → `0.1`) |
| 125 | +- [docs/llds/tracking-engine.md](llds/tracking-engine.md) — tracking engine LLD |
| 126 | + (back-face filter section) |
| 127 | +- [docs/llds/lighthouse-protocol-intelligence.md](llds/lighthouse-protocol-intelligence.md) |
| 128 | + — Protocol Intelligence LLD (filter hooks into this cluster) |
0 commit comments