341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
//! Enrollment protocol — per-anchor capture with an adaptive quality gate
|
||
//! (ADR-151 Stage 2).
|
||
//!
|
||
//! Bad anchors poison small calibrated models far more than large ones, so an
|
||
//! anchor is only *accepted* when its captured statistics match what the anchor
|
||
//! is supposed to teach: a person present (or absent for `empty`), and the
|
||
//! expected stillness/motion. Failed anchors are re-prompted, not silently kept.
|
||
//!
|
||
//! Quality is measured against the ADR-135 empty-room baseline via
|
||
//! [`wifi_densepose_signal::BaselineCalibration::deviation`], whose
|
||
//! `CalibrationDeviationScore` gives a per-frame amplitude z-score (presence
|
||
//! strength).
|
||
//!
|
||
//! **Motion is NOT taken from the score's `motion_flagged`** (ADR-152 finding,
|
||
//! "z-band squeeze"): that flag fires on `amplitude_z_median > 2.0` — deviation
|
||
//! from the *empty* baseline — which conflates presence strength with motion. A
|
||
//! strongly-reflecting person standing perfectly still (z > 2 on every frame)
|
||
//! would be rejected as "too much motion". Instead the recorder derives motion
|
||
//! from the frame-to-frame *change* in the deviation series (|Δz| and |Δφ|),
|
||
//! which is presence-independent: a still strong reflector has high z but a
|
||
//! flat z-series; a moving person has a jittery one.
|
||
|
||
use wifi_densepose_core::types::CsiFrame;
|
||
use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore};
|
||
|
||
use crate::anchor::{Anchor, AnchorLabel, AnchorQuality};
|
||
|
||
/// Thresholds for accepting an anchor.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct AnchorQualityGate {
|
||
/// Minimum mean amplitude z-score to consider a person present.
|
||
pub min_presence_z: f32,
|
||
/// For `empty`: maximum mean z-score to consider the room truly empty.
|
||
pub empty_max_z: f32,
|
||
/// For "still" anchors: maximum motion-flag rate tolerated.
|
||
pub max_still_motion: f32,
|
||
/// For the "move" anchor: minimum motion-flag rate required.
|
||
pub min_move_motion: f32,
|
||
/// Minimum frames required to evaluate an anchor.
|
||
pub min_frames: u32,
|
||
}
|
||
|
||
impl Default for AnchorQualityGate {
|
||
fn default() -> Self {
|
||
Self {
|
||
min_presence_z: 1.5,
|
||
empty_max_z: 1.0,
|
||
max_still_motion: 0.6,
|
||
min_move_motion: 0.3,
|
||
min_frames: 60,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AnchorQualityGate {
|
||
/// Evaluate accumulated stats for `label`, returning the quality verdict
|
||
/// and (on rejection) a human-readable reason.
|
||
pub fn evaluate(
|
||
&self,
|
||
label: AnchorLabel,
|
||
presence_z: f32,
|
||
motion_rate: f32,
|
||
frames: u32,
|
||
) -> (AnchorQuality, Option<String>) {
|
||
let mut reason: Option<String> = None;
|
||
|
||
if frames < self.min_frames {
|
||
reason = Some(format!(
|
||
"only {frames} frames (need ≥{}); is the ESP32 streaming?",
|
||
self.min_frames
|
||
));
|
||
} else if label.expects_presence() {
|
||
if presence_z < self.min_presence_z {
|
||
reason = Some(format!(
|
||
"no person detected (presence_z {presence_z:.2} < {:.2}) — move closer / face the sensor",
|
||
self.min_presence_z
|
||
));
|
||
} else if label.expects_still() && motion_rate > self.max_still_motion {
|
||
reason = Some(format!(
|
||
"too much motion ({:.0}% > {:.0}%) for a still anchor — hold still",
|
||
motion_rate * 100.0,
|
||
self.max_still_motion * 100.0
|
||
));
|
||
} else if !label.expects_still() && motion_rate < self.min_move_motion {
|
||
reason = Some(format!(
|
||
"not enough motion ({:.0}% < {:.0}%) — move a bit more",
|
||
motion_rate * 100.0,
|
||
self.min_move_motion * 100.0
|
||
));
|
||
}
|
||
} else {
|
||
// `empty` anchor: the room must actually be empty.
|
||
if presence_z > self.empty_max_z {
|
||
reason = Some(format!(
|
||
"room not empty (presence_z {presence_z:.2} > {:.2}) — clear the room",
|
||
self.empty_max_z
|
||
));
|
||
}
|
||
}
|
||
|
||
let quality = AnchorQuality {
|
||
presence_z,
|
||
motion_rate,
|
||
frames,
|
||
accepted: reason.is_none(),
|
||
};
|
||
(quality, reason)
|
||
}
|
||
}
|
||
|
||
/// Frame-to-frame amplitude-z change above which a frame counts as motion.
|
||
///
|
||
/// Presence-independent by construction: a still person shifts the z *level*
|
||
/// but not its frame-to-frame delta (only noise-scale jitter survives), while
|
||
/// body movement modulates the reflected paths every frame. Sized well above
|
||
/// the delta the baseline's own noise floor produces (≲0.3σ) and well below
|
||
/// the delta even small limb movements produce (≳1σ). See ADR-152.
|
||
pub const Z_DELTA_MOTION: f32 = 0.5;
|
||
|
||
/// Frame-to-frame phase-drift change above which a frame counts as motion.
|
||
/// Same constant family as the absolute π/6 drift bound in
|
||
/// `CalibrationDeviationScore`, applied to the delta (static body phase shift
|
||
/// cancels out).
|
||
pub const PHASE_DELTA_MOTION: f32 = std::f32::consts::PI / 6.0;
|
||
|
||
/// Accumulates per-frame deviation statistics for a single anchor capture.
|
||
pub struct AnchorRecorder {
|
||
label: AnchorLabel,
|
||
z_sum: f64,
|
||
motion_count: u32,
|
||
frames: u32,
|
||
/// Previous frame's (amplitude_z_median, phase_drift_median) for the
|
||
/// delta-based motion measure (ADR-152 z-band-squeeze fix).
|
||
prev: Option<(f32, f32)>,
|
||
}
|
||
|
||
impl AnchorRecorder {
|
||
/// Start recording the given anchor.
|
||
pub fn new(label: AnchorLabel) -> Self {
|
||
Self {
|
||
label,
|
||
z_sum: 0.0,
|
||
motion_count: 0,
|
||
frames: 0,
|
||
prev: None,
|
||
}
|
||
}
|
||
|
||
/// The anchor being recorded.
|
||
pub fn label(&self) -> AnchorLabel {
|
||
self.label
|
||
}
|
||
|
||
/// Frames recorded so far.
|
||
pub fn frames(&self) -> u32 {
|
||
self.frames
|
||
}
|
||
|
||
/// Record a pre-computed deviation score (caller runs `baseline.deviation`).
|
||
///
|
||
/// Motion is derived from the frame-to-frame change of the deviation
|
||
/// series, NOT from `score.motion_flagged` — the flag conflates presence
|
||
/// strength with motion (z-band squeeze, see module docs / ADR-152). The
|
||
/// first frame of a capture is never motion (no predecessor).
|
||
pub fn record_score(&mut self, score: &CalibrationDeviationScore) {
|
||
let z = score.amplitude_z_median;
|
||
let phase = score.phase_drift_median;
|
||
if let Some((pz, pp)) = self.prev {
|
||
if (z - pz).abs() > Z_DELTA_MOTION || (phase - pp).abs() > PHASE_DELTA_MOTION {
|
||
self.motion_count += 1;
|
||
}
|
||
}
|
||
self.prev = Some((z, phase));
|
||
self.z_sum += z as f64;
|
||
self.frames += 1;
|
||
}
|
||
|
||
/// Convenience: record a CSI frame directly against a baseline.
|
||
/// Frames that fail baseline geometry checks are skipped (not counted).
|
||
pub fn record_frame(&mut self, baseline: &BaselineCalibration, frame: &CsiFrame) {
|
||
if let Ok(score) = baseline.deviation(frame) {
|
||
self.record_score(&score);
|
||
}
|
||
}
|
||
|
||
/// Mean presence z-score over the capture.
|
||
pub fn presence_z(&self) -> f32 {
|
||
if self.frames == 0 {
|
||
0.0
|
||
} else {
|
||
(self.z_sum / self.frames as f64) as f32
|
||
}
|
||
}
|
||
|
||
/// Fraction of frames flagged as motion.
|
||
pub fn motion_rate(&self) -> f32 {
|
||
if self.frames == 0 {
|
||
0.0
|
||
} else {
|
||
self.motion_count as f32 / self.frames as f32
|
||
}
|
||
}
|
||
|
||
/// Evaluate the capture against the gate and produce an `Anchor` (accepted
|
||
/// or not) plus a rejection reason.
|
||
pub fn finalize(&self, gate: &AnchorQualityGate, at_unix_s: i64) -> (Anchor, Option<String>) {
|
||
let (quality, reason) = gate.evaluate(
|
||
self.label,
|
||
self.presence_z(),
|
||
self.motion_rate(),
|
||
self.frames,
|
||
);
|
||
(
|
||
Anchor {
|
||
label: self.label,
|
||
captured_at_unix_s: at_unix_s,
|
||
quality,
|
||
},
|
||
reason,
|
||
)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Build a score the way `BaselineCalibration::deviation` actually would:
|
||
/// `motion_flagged` is DERIVED from z (z > 2.0 ⇒ flagged), never free.
|
||
/// The old tests mocked `(z=3.0, motion=false)` — a combination the real
|
||
/// producer can never emit, which is exactly how the z-band squeeze hid.
|
||
fn score(z: f32) -> CalibrationDeviationScore {
|
||
CalibrationDeviationScore {
|
||
amplitude_z_median: z,
|
||
amplitude_z_max: z + 1.0,
|
||
phase_drift_median: 0.05,
|
||
motion_flagged: z > 2.0,
|
||
}
|
||
}
|
||
|
||
/// Record a z-series and finalize against the default gate.
|
||
fn run_series(label: AnchorLabel, zs: &[f32]) -> (Anchor, Option<String>) {
|
||
let mut r = AnchorRecorder::new(label);
|
||
for &z in zs {
|
||
r.record_score(&score(z));
|
||
}
|
||
r.finalize(&AnchorQualityGate::default(), 100)
|
||
}
|
||
|
||
/// Constant z (a perfectly still capture at the given presence strength).
|
||
fn run_still(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
|
||
run_series(label, &vec![z; n])
|
||
}
|
||
|
||
/// Alternating z (every frame's |Δz| exceeds Z_DELTA_MOTION ⇒ all motion).
|
||
fn run_jittery(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option<String>) {
|
||
let zs: Vec<f32> = (0..n)
|
||
.map(|i| {
|
||
if i % 2 == 0 {
|
||
z
|
||
} else {
|
||
z + 2.0 * Z_DELTA_MOTION
|
||
}
|
||
})
|
||
.collect();
|
||
run_series(label, &zs)
|
||
}
|
||
|
||
/// ADR-152 z-band-squeeze regression: a STRONGLY-reflecting still person
|
||
/// (z = 3.0, so every frame is motion_flagged by the baseline heuristic)
|
||
/// must still pass a still anchor — presence strength is not motion.
|
||
#[test]
|
||
fn still_anchor_with_strong_still_person_accepts() {
|
||
let (a, reason) = run_still(AnchorLabel::StandStill, 3.0, 400);
|
||
assert!(a.quality.accepted, "z-band squeeze is back: {reason:?}");
|
||
assert!(reason.is_none());
|
||
assert!(
|
||
a.quality.motion_rate < 0.05,
|
||
"flat z-series must read still"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn still_anchor_rejects_when_no_presence() {
|
||
let (a, reason) = run_still(AnchorLabel::Sit, 0.4, 400);
|
||
assert!(!a.quality.accepted);
|
||
assert!(reason.unwrap().contains("no person"));
|
||
}
|
||
|
||
#[test]
|
||
fn still_anchor_rejects_on_motion() {
|
||
let (a, reason) = run_jittery(AnchorLabel::LieDown, 3.0, 400);
|
||
assert!(!a.quality.accepted);
|
||
assert!(reason.unwrap().contains("motion"));
|
||
}
|
||
|
||
#[test]
|
||
fn move_anchor_requires_motion() {
|
||
let (still, r1) = run_still(AnchorLabel::SmallMove, 3.0, 400);
|
||
assert!(!still.quality.accepted);
|
||
assert!(r1.unwrap().contains("not enough motion"));
|
||
let (moving, r2) = run_jittery(AnchorLabel::SmallMove, 3.0, 400);
|
||
assert!(moving.quality.accepted, "reason: {r2:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn phase_delta_also_counts_as_motion() {
|
||
// Constant z but a phase-drift series that swings past PHASE_DELTA_MOTION
|
||
// every frame — motion must be detected from the phase channel alone.
|
||
let mut r = AnchorRecorder::new(AnchorLabel::LieDown);
|
||
for i in 0..400 {
|
||
let mut s = score(1.8);
|
||
s.phase_drift_median = if i % 2 == 0 {
|
||
0.0
|
||
} else {
|
||
PHASE_DELTA_MOTION * 1.5
|
||
};
|
||
r.record_score(&s);
|
||
}
|
||
let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100);
|
||
assert!(!a.quality.accepted);
|
||
assert!(reason.unwrap().contains("motion"));
|
||
}
|
||
|
||
#[test]
|
||
fn empty_anchor_rejects_when_occupied() {
|
||
let (occupied, reason) = run_still(AnchorLabel::Empty, 3.0, 400);
|
||
assert!(!occupied.quality.accepted);
|
||
assert!(reason.unwrap().contains("not empty"));
|
||
let (empty, _) = run_still(AnchorLabel::Empty, 0.3, 400);
|
||
assert!(empty.quality.accepted);
|
||
}
|
||
|
||
#[test]
|
||
fn too_few_frames_rejected() {
|
||
let (a, reason) = run_still(AnchorLabel::Sit, 3.0, 10);
|
||
assert!(!a.quality.accepted);
|
||
assert!(reason.unwrap().contains("frames"));
|
||
}
|
||
}
|