Skip to content

Commit 5f3c90b

Browse files
ruvnetReuven
andauthored
fix(sensing-server): add real hysteresis to person count estimation (#295)
The person-count heuristic was causing widespread flickering (#237, #249, #280, #292) because: 1. Threshold 0.50 for 2-persons was too low — multipath reflections in small rooms easily exceeded it 2. No actual hysteresis despite the comment claiming asymmetric thresholds 3. EMA smoothing (α=0.15) was too responsive to transient spikes Changes: - Raise up-thresholds: 1→2 persons at 0.65 (was 0.50), 2→3 at 0.85 (was 0.80) - Add true hysteresis with asymmetric down-thresholds: 2→1 at 0.45, 3→2 at 0.70 - Track prev_person_count in SensingState for state-aware transitions - Increase EMA smoothing to α=0.10 (~2s time constant at 20 Hz) - Update all 4 call sites (ESP32, Windows WiFi, multi-BSSID, simulated) Fixes #292, #280, #237 Co-authored-by: Reuven <cohen@ruv-mac-mini.local>
1 parent 4713a30 commit 5f3c90b

1 file changed

Lines changed: 66 additions & 24 deletions

File tree

  • rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src

rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ struct AppStateInner {
304304
model_loaded: bool,
305305
/// Smoothed person count (EMA) for hysteresis — prevents frame-to-frame jumping.
306306
smoothed_person_score: f64,
307+
/// Previous person count for hysteresis (asymmetric up/down thresholds).
308+
prev_person_count: usize,
307309
// ── Motion smoothing & adaptive baseline (ADR-047 tuning) ────────────
308310
/// EMA-smoothed motion score (alpha ~0.15 for ~10 FPS → ~1s time constant).
309311
smoothed_motion: f64,
@@ -1247,12 +1249,15 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
12471249

12481250
let feat_variance = features.variance;
12491251

1250-
// Multi-person estimation with temporal smoothing (EMA α=0.15).
1252+
// Multi-person estimation with temporal smoothing (EMA α=0.10).
12511253
let raw_score = compute_person_score(&features);
1252-
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
1254+
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
12531255
let est_persons = if classification.presence {
1254-
score_to_person_count(s.smoothed_person_score)
1256+
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
1257+
s.prev_person_count = count;
1258+
count
12551259
} else {
1260+
s.prev_person_count = 0;
12561261
0
12571262
};
12581263

@@ -1377,12 +1382,15 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
13771382

13781383
let feat_variance = features.variance;
13791384

1380-
// Multi-person estimation with temporal smoothing.
1385+
// Multi-person estimation with temporal smoothing (EMA α=0.10).
13811386
let raw_score = compute_person_score(&features);
1382-
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
1387+
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
13831388
let est_persons = if classification.presence {
1384-
score_to_person_count(s.smoothed_person_score)
1389+
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
1390+
s.prev_person_count = count;
1391+
count
13851392
} else {
1393+
s.prev_person_count = 0;
13861394
0
13871395
};
13881396

@@ -1724,18 +1732,45 @@ fn compute_person_score(feat: &FeatureInfo) -> f64 {
17241732

17251733
/// Convert smoothed person score to discrete count with hysteresis.
17261734
///
1727-
/// Uses asymmetric thresholds: higher threshold to add a person, lower to remove.
1728-
/// This prevents flickering at the boundary.
1729-
fn score_to_person_count(smoothed_score: f64) -> usize {
1730-
// Thresholds chosen conservatively for single-ESP32 link:
1731-
// score > 0.50 → 2 persons (needs sustained high variance + change points)
1732-
// score > 0.80 → 3 persons (very high activity, rare with single link)
1733-
if smoothed_score > 0.80 {
1734-
3
1735-
} else if smoothed_score > 0.50 {
1736-
2
1737-
} else {
1738-
1
1735+
/// Uses asymmetric thresholds: higher threshold to *add* a person, lower to
1736+
/// *drop* one. This prevents flickering when the score hovers near a boundary
1737+
/// (the #1 user-reported issue — see #237, #249, #280, #292).
1738+
fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize {
1739+
// Up-thresholds (must exceed to increase count):
1740+
// 1→2: 0.65 (raised from 0.50 — multipath in small rooms hit 0.50 easily)
1741+
// 2→3: 0.85 (raised from 0.80 — 3 persons needs strong sustained signal)
1742+
// Down-thresholds (must drop below to decrease count):
1743+
// 2→1: 0.45 (hysteresis gap of 0.20)
1744+
// 3→2: 0.70 (hysteresis gap of 0.15)
1745+
match prev_count {
1746+
0 | 1 => {
1747+
if smoothed_score > 0.85 {
1748+
3
1749+
} else if smoothed_score > 0.65 {
1750+
2
1751+
} else {
1752+
1
1753+
}
1754+
}
1755+
2 => {
1756+
if smoothed_score > 0.85 {
1757+
3
1758+
} else if smoothed_score < 0.45 {
1759+
1
1760+
} else {
1761+
2 // hold — within hysteresis band
1762+
}
1763+
}
1764+
_ => {
1765+
// prev_count >= 3
1766+
if smoothed_score < 0.45 {
1767+
1
1768+
} else if smoothed_score < 0.70 {
1769+
2
1770+
} else {
1771+
3 // hold
1772+
}
1773+
}
17391774
}
17401775
}
17411776

@@ -2824,12 +2859,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
28242859
let vitals = smooth_vitals(&mut s, &raw_vitals);
28252860
s.latest_vitals = vitals.clone();
28262861

2827-
// Multi-person estimation with temporal smoothing.
2862+
// Multi-person estimation with temporal smoothing (EMA α=0.10).
28282863
let raw_score = compute_person_score(&features);
2829-
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
2864+
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
28302865
let est_persons = if classification.presence {
2831-
score_to_person_count(s.smoothed_person_score)
2866+
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
2867+
s.prev_person_count = count;
2868+
count
28322869
} else {
2870+
s.prev_person_count = 0;
28332871
0
28342872
};
28352873

@@ -2929,12 +2967,15 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
29292967
let frame_amplitudes = frame.amplitudes.clone();
29302968
let frame_n_sub = frame.n_subcarriers;
29312969

2932-
// Multi-person estimation with temporal smoothing.
2970+
// Multi-person estimation with temporal smoothing (EMA α=0.10).
29332971
let raw_score = compute_person_score(&features);
2934-
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
2972+
s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10;
29352973
let est_persons = if classification.presence {
2936-
score_to_person_count(s.smoothed_person_score)
2974+
let count = score_to_person_count(s.smoothed_person_score, s.prev_person_count);
2975+
s.prev_person_count = count;
2976+
count
29372977
} else {
2978+
s.prev_person_count = 0;
29382979
0
29392980
};
29402981

@@ -3577,6 +3618,7 @@ async fn main() {
35773618
active_sona_profile: None,
35783619
model_loaded,
35793620
smoothed_person_score: 0.0,
3621+
prev_person_count: 0,
35803622
smoothed_motion: 0.0,
35813623
current_motion_level: "absent".to_string(),
35823624
debounce_counter: 0,

0 commit comments

Comments
 (0)