Skip to content

Commit dc6ac68

Browse files
rocketmarkclaude
andcommitted
Document back-face filter and angular rate gates; add frame-transform test
- back-face-filter-hld.md: HLD for enabling --filter-normal-facingness 0.1 - lighthouse-protocol-intelligence LLD: Stage 1 back-face filter section, updated data flow diagram - tracking-engine LLD: lc-angular-rate-max and kalman-max-pose-angular-rate documented; added to design decisions table - reflection-rejection.md: update test inventory (6 → 7 tests) - LPI specs: LPI-PROC-060–066 (back-face filter guards and behavior) - TE specs: TE-PROC-040–042 (input and output angular rate gates) - normal_filter_props.c: add SensorNormalBodyToWorldTransform test verifying that sensor_normals[] are body-frame and quatrotatevector produces correct world-frame normals (90°/180°/identity cases) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8a59506 commit dc6ac68

7 files changed

Lines changed: 309 additions & 3 deletions

docs/back-face-filter-hld.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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)

docs/llds/lighthouse-protocol-intelligence.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,60 @@ the filter maintains:
9292
- Timecode of last valid reading
9393
- Motion detection via gyro/accel/light variance thresholds
9494

95+
The filter is the earliest interception point in the pipeline and runs two
96+
rejection stages in order before any measurement reaches the Kalman tracker.
97+
98+
#### Stage 1 — Back-Face Normal Filter
99+
100+
A geometry-based pre-filter that rejects sensor hits that are physically
101+
impossible given the tracker's current orientation. Lighthouse sweep light
102+
cannot illuminate a sensor whose face points away from the lighthouse; such
103+
hits are almost certainly specular reflections from hard surfaces (LED walls,
104+
glass, monitor bezels).
105+
106+
For each incoming hit `(sensor_id, lh, axis)`, the filter computes:
107+
108+
```c
109+
normalInWorld = quatrotatevector(OutPose.Rot, sensor_normals[sensor_id])
110+
sensorInWorld = ApplyPoseToPoint(OutPose, sensor_locations[sensor_id])
111+
towardLH = normalize(bsd[lh].Pose.Pos - sensorInWorld)
112+
facingness = dot(normalInWorld, towardLH)
113+
114+
if facingness < filterNormalFacingness: reject
115+
```
116+
117+
The sensor normal is stored in the object's local frame (`sensor_normals`
118+
field); `quatrotatevector` maps it to world frame using the current tracker
119+
pose. `facingness = 1.0` means the sensor faces directly toward the lighthouse;
120+
`facingness = 0.0` is the geometric horizon; `facingness = -1.0` is directly
121+
away.
122+
123+
**Guards** (any failing guard skips the filter entirely, passing the hit):
124+
- `filterNormalFacingness < -0.5` — disabled (threshold below -0.5 disables)
125+
- `so->sensor_normals == NULL` — device has no normals data (not all trackers)
126+
- `so->poseConfidence < filterNormalMinConfidence` — pose not yet reliable
127+
enough to trust the world-frame normal computation
128+
- `!ctx->bsd[lh].PositionSet` — lighthouse not yet calibrated
129+
130+
**Config items:**
131+
132+
| Flag | Default | Description |
133+
|---|---|---|
134+
| `--filter-normal-facingness` | `0.0` | Min dot product to accept. `< -0.5` disables. Stagehand deploys `0.1` (~84° cone). |
135+
| `--filter-normal-min-confidence` | `0.1` | Min pose confidence before filter activates. |
136+
137+
**Property tests:** `src/test_cases/normal_filter_props.c` (6 tests) covers
138+
`FacingnessInRange`, `FacingnessFlipsWithDirection`, `FacingnessKnownAngle`,
139+
`FacingnessThresholdMonotonic`, `DirectlyFacingAlwaysAccepted`,
140+
`BackFacingAlwaysRejected`.
141+
142+
**Reference:** Ported from the simulator path at `src/driver_simulator.c:152–163`.
143+
The same filter exists there for virtual hardware; this change brings it to the
144+
real hardware path. See `docs/reflection-rejection.md` (Change 1) for full
145+
implementation details and `docs/back-face-filter-hld.md` for design rationale.
146+
147+
#### Stage 2 — Chauvenet Statistical Outlier Rejection
148+
95149
Outliers are rejected using the Chauvenet criterion: a new measurement is
96150
discarded if it falls more than N standard deviations from the running mean,
97151
where the threshold is configured by `angle-reject-outliers`. This prevents
@@ -226,7 +280,9 @@ survive_disambiguator.c
226280
227281
228282
survive_sensor_activations.c
229-
│ outlier rejection, motion detection, validity gating
283+
│ Stage 1: back-face normal filter (geometry-based)
284+
│ Stage 2: Chauvenet outlier rejection
285+
│ validity gating (require both axes per LH)
230286
231287
Tracking Engine (angles + BaseStationCal)
232288
```

docs/llds/tracking-engine.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,37 @@ the filter from drifting during stationary periods.
257257
`kalman-light-threshold-var` are rejected before the update step. This prevents
258258
large outliers (e.g., reflected light, interference) from corrupting the filter.
259259
260+
**Input-level lightcap angular rate gate (`lc-angular-rate-max`):** Before
261+
accepting a lightcap batch, the filter predicts the pose at the batch timestamp
262+
and computes the angular distance from the last accepted batch's pose. If the
263+
implied angular rate (rad/s) exceeds `lc_angular_rate_max`, the batch is
264+
dropped without updating the Kalman state. This is a pre-update gate — the
265+
filter state is never touched for rejected batches. After a batch passes, the
266+
reference rotation and timestamp are updated for use on the next batch.
267+
268+
Config: `--lc-angular-rate-max` (default: disabled). The reference is stored in
269+
`last_accepted_lc_rot` / `last_accepted_lc_time` in `SurviveKalmanTracker`.
270+
271+
**Note on "defending wrong state":** This gate can lock out valid data if a
272+
prior reflection batch corrupted the Kalman state before the gate could fire —
273+
subsequent valid batches appear inconsistent with the corrupted state and are
274+
themselves rejected. The back-face filter in Protocol Intelligence (`Stage 1`
275+
of `SurviveSensorActivations_check_outlier`) addresses this by preventing
276+
reflections from entering the pipeline before the Kalman state can be corrupted.
277+
278+
**Output-level pose angular rate gate (`kalman-max-pose-angular-rate`):** After
279+
a pose is computed in `report_state()`, the gate compares it to the last
280+
*emitted* pose. If the angular rate between the two exceeds the threshold, the
281+
pose is suppressed (not emitted to the application) and `stats.dropped_poses`
282+
is incremented. Unlike the input gate above, the Kalman internal state is
283+
already updated when this gate fires — it prevents bad output but does not
284+
prevent internal state drift.
285+
286+
Config: `--kalman-max-pose-angular-rate` (default: -1, disabled). Reference
287+
stored in `last_reported_pose_rot` / `last_report_time`.
288+
289+
See `docs/reflection-rejection.md` (Change 2) for implementation details.
290+
260291
**Iterative IEKF updates for light:** Light measurements use the Iterated EKF
261292
(IEKF) update, which re-linearizes around the current estimate after each step.
262293
This handles the nonlinearity of the reprojection model and prevents
@@ -469,6 +500,9 @@ Library Infrastructure (pose hooks → application)
469500
| Adaptive R from residuals | R updated per-measurement from observed residuals | `cnkalman` adaptive R | Sensor noise varies by position, occlusion, reflection; learned R is more accurate than fixed R |
470501
| Per-LH adaptive R scaling | `lh_var = base_var * max(1.0, lh_res / mean_res)` | `survive_kalman_tracker.c` per-LH batch loop; `light_residuals[lh]` EWMA | Fixed R gives equal Kalman weight to all LHs; miscalibrated LHs pull state proportionally harder with more LHs active, causing jitter that grows with LH count |
471502
| Per-LH innovation gate disabled by default | `light_outlier_threshold = 0`; threshold of 5.0 recommended | Config item in `survive_kalman_tracker.c` | Gate fires on any anomalous frame; conservative default avoids false positives on new hardware or unusual geometry; opt-in once threshold is calibrated for the environment |
503+
| Input-level lightcap angular rate gate | `lc_angular_rate_max`; pre-update, drops batch before Kalman touch | `SurviveKalmanTracker.lc_angular_rate_max`, `last_accepted_lc_rot` in header | Prevents high-velocity batches from being applied; pairs with back-face filter — filter prevents the "defending wrong state" failure mode where this gate itself locks out valid data after a corruption |
504+
| Output-level pose angular rate gate | `max_pose_angular_rate`; post-update, suppresses emission | `survive_kalman_tracker_report_state()`, `stats.dropped_poses` | Guards downstream consumers from sudden pose jumps; Kalman state already updated, so this is a backstop for output quality, not state integrity |
505+
| Back-face filter as upstream prevention | Geometry check in Protocol Intelligence before Kalman | `SurviveSensorActivations_check_outlier()`, `filterNormalFacingness` | Reflections enter the Kalman pipeline only if the filter misses them; back-face filter eliminates the most common class (impossible geometry) before any downstream gate can be challenged |
472506
| FLT type (float/double configurable) | All math uses `FLT` typedef | `libs/cnmatrix/include/cnmatrix/cn_flt.h` | Float gives 2× speed on SIMD for acceptable precision; double available for calibration tools |
473507
474508
## Technical Debt & Inconsistencies

docs/reflection-rejection.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,13 @@ Added to the existing `quat_props.c` test suite:
280280

281281
`QuatDistKnownAngle` is the test that caught the swapped-clamp bug.
282282

283-
### `src/test_cases/normal_filter_props.c`new file (6 tests)
283+
### `src/test_cases/normal_filter_props.c`7 tests
284284

285285
Property tests for the back-facing normal filter geometric invariants:
286286

287287
| Test | Property |
288288
|---|---|
289+
| `SensorNormalBodyToWorldTransform` | sensor_normals[] are in body frame: `quatrotatevector(rot, normal_body)` produces the correct world-frame normal for 90°/180°/identity rotations |
289290
| `FacingnessInRange` | Dot product of unit normal and unit direction is always in `[-1, 1]` |
290291
| `FacingnessFlipsWithDirection` | `dot(n, d) + dot(n, -d) = 0` |
291292
| `FacingnessKnownAngle` | Facingness equals `cos(θ)` for sensor at known angle θ from lighthouse |

docs/specs/lighthouse-protocol-intelligence-specs.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ Prefix: **LPI**
4949
- [x] **LPI-PROC-052**: The system shall require valid measurements on both X and Y axes for a given lighthouse before passing that lighthouse's data to the Tracking Engine.
5050
- [x] **LPI-PROC-053**: While the tracked object is in motion (detected via IMU or angle variance), the system shall reset per-sensor running statistics to prevent stale mean values from rejecting valid measurements.
5151

52+
## Back-Face Normal Filter
53+
54+
- [ ] **LPI-PROC-060**: When `filter-normal-facingness` ≥ -0.5, the system shall evaluate each incoming sensor hit against the back-face criterion before any other filtering: compute `facingness = dot(quatrotatevector(OutPose.Rot, sensor_normals[sensor_id]), normalize(bsd[lh].Pose.Pos - sensorInWorld))` and reject the hit if `facingness < filterNormalFacingness`.
55+
- [ ] **LPI-PROC-061**: When `filter-normal-facingness` < -0.5, the system shall skip the back-face filter entirely and pass the hit to subsequent filtering stages unchanged.
56+
- [ ] **LPI-PROC-062**: If the tracked object's `sensor_normals` or `sensor_locations` field is NULL, the system shall skip the back-face filter for that object (not all device types carry sensor geometry data).
57+
- [ ] **LPI-PROC-063**: While `poseConfidence` is below `filter-normal-min-confidence`, the system shall skip the back-face filter (pose not yet reliable enough to compute a trustworthy world-frame normal).
58+
- [ ] **LPI-PROC-064**: If the lighthouse referenced by the incoming hit does not yet have `PositionSet`, the system shall skip the back-face filter for that hit (lighthouse position needed to compute direction-to-LH).
59+
- [ ] **LPI-PROC-065**: The back-face filter shall run before the Chauvenet outlier filter so that geometrically impossible hits are rejected upstream, before they influence the per-sensor running statistics.
60+
- [ ] **LPI-PROC-066**: When `filter-normal-facingness` ≥ -0.5 and a hit is rejected by the back-face filter, the system shall emit a `SV_VERBOSE(105)` log line including the measured facingness, the threshold, and the (lh, sensor_id, axis) triple.
61+
5262
## Reprojection Model
5363

5464
- [x] **LPI-DATA-060**: The system shall implement the Gen1 reprojection model: `angle = atan2(axis, -Z) - phase - tilt×other_axis - curve×other_axis² - gibmag×sin(angle + gibpha)`.

docs/specs/tracking-engine-specs.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ Prefix: **TE**
4040
- [x] **TE-PROC-037**: When tracking is lost (no valid measurements for the configured timeout period), the system shall reset the Kalman state covariance to reflect high uncertainty.
4141
- [x] **TE-PROC-038**: The system shall scale each lighthouse's observation noise covariance R by the ratio of that lighthouse's EWMA residual to the fleet mean residual, so that lighthouses with above-average residuals receive proportionally less Kalman weight.
4242
- [x] **TE-PROC-039**: Where `light-outlier-threshold` is > 0, the system shall compute the pre-update RMS innovation for each lighthouse batch and skip that lighthouse's Kalman update for the current sync cycle if the RMS exceeds `light-outlier-threshold × light_residuals_all`.
43+
- [ ] **TE-PROC-040**: Where `lc-angular-rate-max` is > 0, the system shall predict the Kalman pose at each incoming lightcap batch's timestamp and compute the angular rate implied by the rotation change from the last accepted batch; if the rate exceeds `lc_angular_rate_max` rad/s the entire batch shall be dropped without modifying the Kalman state.
44+
- [ ] **TE-PROC-041**: When a lightcap batch passes the `lc-angular-rate-max` gate, the system shall update `last_accepted_lc_rot` and `last_accepted_lc_time` to the predicted pose at that batch's timestamp, so subsequent batches are gated against a current reference.
45+
- [ ] **TE-PROC-042**: Where `kalman-max-pose-angular-rate` is > 0, the system shall compute the angular rate between the current predicted pose and the last emitted pose; if the rate exceeds `kalman-max-pose-angular-rate` rad/s the system shall suppress pose emission for that frame and increment `stats.dropped_poses` without modifying the Kalman state.
4346

4447
## Kalman Lighthouse Tracker
4548

0 commit comments

Comments
 (0)