diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index ac5fe4bd0..41fb39867 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -708,6 +708,18 @@ fn update_edge_node_classification(n: &mut NodeState, vitals: &Esp32VitalsPacket }; } +/// Raw-CSI nodes gate their count on the node's own published level, the same +/// decision [`node_inference_for`] feeds to the room vote. Gating on the +/// per-frame classification let an adaptive-model label hold the count at 1 +/// while the node and the room both read `absent`. +fn update_node_person_count(n: &mut NodeState) { + n.prev_person_count = if n.current_motion_level != "absent" { + score_to_person_count(n.smoothed_person_score, n.prev_person_count) + } else { + 0 + }; +} + /// Build a node's *own* [`NodeInference`] from its smoothed per-node state /// (ADR-297). Uses the node's own `current_motion_level` — never the room /// aggregate — with the latest classification confidence (or the legacy score @@ -4015,9 +4027,11 @@ fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, state.debounce_counter = 1; } - // 6. Write the smoothed result back into the classification. + // 6. Write the smoothed result back into the classification. Presence + // follows the debounced level (as the ADR-297 room vote does) so a + // frame never reads `absent` while gating a person count of 1. raw.motion_level = state.current_motion_level.clone(); - raw.presence = sm > 0.03; + raw.presence = raw.motion_level != "absent"; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } @@ -4064,10 +4078,131 @@ fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, ra } raw.motion_level = ns.current_motion_level.clone(); - raw.presence = sm > 0.03; + raw.presence = raw.motion_level != "absent"; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } +#[cfg(test)] +mod smoothed_presence_tests { + //! Every person count is gated on `classification.presence`, so presence + //! must agree with the debounced motion level that is published next to + //! it. A smoothed score in (0.03, 0.04] used to read `absent` with + //! `presence: true`, which held the count at 1 in a quiet room. + use super::*; + + /// Steady quiet-room motion: the 0.7 baseline subtraction leaves + /// 0.3 * 0.117 = 0.035, inside the old disagreement band. + const QUIET_ROOM_MOTION: f64 = 0.117; + + #[test] + fn node_presence_matches_debounced_level() { + for raw_motion in [0.0, QUIET_ROOM_MOTION, 0.2, 1.0] { + let mut ns = NodeState::new(); + ns.csi_fps_ema = 10.0; + for frame in 0..600 { + let mut c = ClassificationInfo { + motion_level: raw_classify(raw_motion), + presence: raw_motion > 0.04, + confidence: 0.5, + }; + smooth_and_classify_node(&mut ns, &mut c, raw_motion); + assert_eq!( + c.presence, + c.motion_level != "absent", + "raw_motion={raw_motion} frame={frame} level={} sm={}", + c.motion_level, + ns.smoothed_motion + ); + } + } + } + + #[test] + fn quiet_node_reports_absent_and_no_presence() { + let mut ns = NodeState::new(); + ns.csi_fps_ema = 10.0; + let mut c = ClassificationInfo { + motion_level: "absent".into(), + presence: false, + confidence: 0.5, + }; + for _ in 0..600 { + smooth_and_classify_node(&mut ns, &mut c, QUIET_ROOM_MOTION); + } + assert!(ns.smoothed_motion > 0.03 && ns.smoothed_motion <= 0.04); + assert_eq!(c.motion_level, "absent"); + assert!( + !c.presence, + "an absent node must not gate a person count of 1" + ); + } + + #[test] + fn room_presence_matches_debounced_level() { + for raw_motion in [0.0, QUIET_ROOM_MOTION, 0.2, 1.0] { + let mut s = AppStateInner::minimal(); + for frame in 0..600 { + let mut c = ClassificationInfo { + motion_level: raw_classify(raw_motion), + presence: raw_motion > 0.04, + confidence: 0.5, + }; + smooth_and_classify(&mut s, &mut c, raw_motion); + assert_eq!( + c.presence, + c.motion_level != "absent", + "raw_motion={raw_motion} frame={frame} level={} sm={}", + c.motion_level, + s.smoothed_motion + ); + } + } + } + + #[test] + fn absent_node_count_is_zero_whatever_the_frame_label() { + let mut ns = NodeState::new(); + ns.current_motion_level = "absent".into(); + ns.smoothed_person_score = corr_persons_to_score(1); + ns.prev_person_count = 1; + update_node_person_count(&mut ns); + assert_eq!(ns.prev_person_count, 0); + // The room vote reads the same field, so both say empty. + assert_eq!( + node_inference_for(&ns, std::time::Instant::now()).classification, + "absent" + ); + } + + #[test] + fn present_node_count_is_at_least_one() { + let mut ns = NodeState::new(); + ns.current_motion_level = "present_still".into(); + ns.smoothed_person_score = 0.0; + update_node_person_count(&mut ns); + assert_eq!(ns.prev_person_count, 1); + ns.smoothed_person_score = corr_persons_to_score(2); + update_node_person_count(&mut ns); + assert_eq!(ns.prev_person_count, 2); + } + + #[test] + fn sustained_motion_still_reports_presence() { + let mut ns = NodeState::new(); + ns.csi_fps_ema = 10.0; + let mut c = ClassificationInfo { + motion_level: "absent".into(), + presence: false, + confidence: 0.5, + }; + for _ in 0..600 { + smooth_and_classify_node(&mut ns, &mut c, 1.0); + } + assert_ne!(c.motion_level, "absent"); + assert!(c.presence); + } +} + /// If an adaptive model is loaded, override the classification with the /// model's prediction. Uses the full 15-feature vector for higher accuracy. fn adaptive_override( @@ -10801,13 +10936,7 @@ async fn udp_receiver_task( // 0.70 up-threshold — so the count was pinned at 1. let raw_score = corr_persons_to_score(corr_persons); ns.smoothed_person_score = ns.smoothed_person_score * 0.92 + raw_score * 0.08; - if classification.presence { - let count = - score_to_person_count(ns.smoothed_person_score, ns.prev_person_count); - ns.prev_person_count = count; - } else { - ns.prev_person_count = 0; - } + update_node_person_count(ns); // Store latest features on node for cross-node fusion. ns.latest_classification_confidence = Some(classification.confidence);