@@ -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