mirror of
https://github.com/ruvnet/RuView
synced 2026-08-04 19:31:42 +00:00
fix: ESP32 vitals over-count + presence flicker (#998/#996) + Observatory per-person position/motion (#1050) (#1060)
* fix(firmware): gate phantom persons + add presence hysteresis (#998, #996) Two ESP32 edge-vitals logic bugs in edge_processing.c. Both are robustness/logic fixes — NOT validated-accuracy claims. True count/PCK vs labelled ground truth remains hardware/data-gated (COM9 ESP32-S3). #998 — n_persons over-counted (reported 4 for one person): update_multi_person_vitals() split top-K subcarriers into top_k_count/2 groups and marked EVERY group active, so one body's multipath always read the full EDGE_MAX_PERSONS. Added two pure, host-testable helpers: - count_distinct_persons(): per-group energy gate (EDGE_PERSON_MIN_ENERGY_RATIO) + spatial dedup (EDGE_PERSON_MIN_SC_SEP) so weak/adjacent multipath groups don't count as separate bodies. Strongest group always counts (>=1). - person_count_debounce(): a gated count must hold EDGE_PERSON_PERSIST_FRAMES consecutive frames before it's emitted, so a single noisy frame can't promote a phantom. The active flags now mark only the strongest stable_count groups. #996 — presence flag flickered at ~50cm despite high presence_score: the bare `score > threshold` compare chattered on a noisy score (field-observed 2.6-26.7 frame-to-frame). Replaced with a Schmitt trigger + clear-debounce (presence_flag_update): assert above threshold, hold in the dead band down to threshold * EDGE_PRESENCE_HYST_RATIO, clear only after EDGE_PRESENCE_CLEAR_FRAMES consecutive sub-low frames. presence_score itself is unchanged and still emitted for consumer-side thresholding. All thresholds are named, documented constants in edge_processing.h. Firmware builds clean for esp32s3 (idf.py build RC=0). Co-Authored-By: claude-flow <ruv@ruv.net> * test(firmware): host C99 tests for vitals count + presence logic (#998, #996) test/test_vitals_count_presence.c pins the two fixes with deterministic host-buildable tests (no ESP-IDF needed). 13 cases / 22 assertions, all passing under gcc 13 -Wall -Wextra: #998 count gate: single strong signature + multipath -> count==1; two well-separated -> 2; two strong-but-adjacent -> 1 (dedup); no signal -> 0; three well-separated -> 3. #998 debounce: transient spike rejected; sustained change accepted; flapping count stays stable. #996 presence: dithering trace -> stable flag (no flicker); brief dips held by clear-debounce; genuine departure clears within hold window; dead-band holds state. The named tuning constants are #include'd from the real edge_processing.h so the test and firmware can never disagree on thresholds. `make run_vitals` / `make host_tests` added; binaries gitignored. Hardware-gated caveat documented in the test header: these pin the decision LOGIC; the exact energy/separation/hysteresis values that best match a real room vs labelled occupancy remain on-device tuning. Co-Authored-By: claude-flow <ruv@ruv.net> * docs: record ESP32 vitals count/presence fixes (#998, #996) CHANGELOG [Unreleased] Fixed: root cause + fix + named constants + test + explicit hardware/data-gated caveat for both bugs. ADR-021 Implementation Notes: dated 2026-06 entry noting the edge-path person-count + presence-flicker fixes are boolean/count emission-logic fixes, not a validated-accuracy claim; thresholds pending on-device calibration. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(sensing-server): emit real field-derived person position/motion to /ws/sensing (#1050) The Observatory 3D figure never animated because the sensing_update WS frame carried no per-person position/motion_score/pose — only image-space keypoints. The FigurePool/PoseSystem (and demo-data.js's own contract) animate each figure from persons[i].position (room-world), .motion_score (0..100), and .pose; none were on the live stream. Honest scope (Case 2): the pipeline has no calibrated per-person room localizer or per-person skeletal pose. New field_localize module extracts the strongest peak(s) from the real signal_field grid (subcarrier variances x motion-band power) and maps the peak cell to Observatory world coords with the exact _buildSignalField transform. motion_score is the measured motion_band_power passed through; pose is set only from a real aggregate posture estimate, else None (never a fabricated skeleton). Empty/below-threshold field -> persons: [] (no phantom); present person with no resolvable peak keeps position [0,0,0], not invented coords. attach_field_positions runs after the tracker step at all five broadcast sites. New position/motion_score/pose fields added to both PersonDetection structs. No UI change needed — the Observatory already reads these fields. Tests: field_localize peak/coordinate/empty/separation units + observatory_persons_field_position_tests (known-peak -> emitted position, empty-room -> no phantom, pose real-or-None, below-threshold honesty). sensing-server bin 441->451, 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(changelog): record #1050 Observatory persons position/motion fix Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -38,6 +38,30 @@
|
||||
/* ---- Multi-person ---- */
|
||||
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
|
||||
|
||||
/* ---- Multi-person counting gates (issue #998) ----
|
||||
*
|
||||
* Over-counting root cause: the multi-person path used to split the top-K
|
||||
* subcarriers into EDGE_MAX_PERSONS groups and mark EVERY group active,
|
||||
* so one body's multipath always reported the full EDGE_MAX_PERSONS. These
|
||||
* gates promote a subcarrier group to a real "person" only when it carries
|
||||
* genuine, distinct, persistent energy:
|
||||
*
|
||||
* 1. Energy gate — a group's phase variance must exceed a fraction of the
|
||||
* strongest group's variance, else it is multipath/noise.
|
||||
* 2. Spatial dedup — two groups whose representative subcarriers sit within
|
||||
* EDGE_PERSON_MIN_SC_SEP of each other are the same body
|
||||
* (adjacent subcarriers see correlated reflections), so
|
||||
* the weaker one is merged away.
|
||||
* 3. Persistence — a candidate count must hold for EDGE_PERSON_PERSIST_FRAMES
|
||||
* consecutive decisions before it is emitted, so a single
|
||||
* noisy frame cannot promote a phantom person.
|
||||
*
|
||||
* These are robustness gates on the existing heuristic, not a calibrated
|
||||
* occupancy model — true count accuracy vs ground truth remains data-gated. */
|
||||
#define EDGE_PERSON_MIN_ENERGY_RATIO 0.35f /**< Group var must be >= this * max group var to count. */
|
||||
#define EDGE_PERSON_MIN_SC_SEP 4 /**< Min subcarrier separation between distinct persons. */
|
||||
#define EDGE_PERSON_PERSIST_FRAMES 3 /**< Consecutive decisions a count must hold before emit. */
|
||||
|
||||
/* ---- Calibration ---- */
|
||||
#define EDGE_CALIB_FRAMES 1200 /**< Frames for adaptive calibration (~60s at 20 Hz). */
|
||||
#define EDGE_CALIB_SIGMA_MULT 3.0f /**< Threshold = mean + 3*sigma of ambient. */
|
||||
@@ -46,6 +70,27 @@
|
||||
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
|
||||
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
|
||||
|
||||
/* ---- Presence flag hysteresis + debounce (issue #996) ----
|
||||
*
|
||||
* Flicker root cause: the presence flag was a single-threshold compare on a
|
||||
* noisy presence_score (observed 2.6-26.7 frame-to-frame for one stationary
|
||||
* person), so the boolean chattered at the boundary even while the score
|
||||
* clearly indicated a person. Fix: Schmitt-trigger hysteresis plus a clear
|
||||
* debounce.
|
||||
*
|
||||
* - Assert presence when score > threshold (enter immediately).
|
||||
* - Hold presence while score >= threshold * HYST_RATIO (no flicker in the
|
||||
* gap band).
|
||||
* - Clear presence only after the score stays below the low threshold for
|
||||
* EDGE_PRESENCE_CLEAR_FRAMES consecutive frames (genuine departure).
|
||||
*
|
||||
* HYST_RATIO < 1.0 sets the low threshold below the high threshold; a wider gap
|
||||
* (smaller ratio) is more flicker-immune but slower to clear on real exit. The
|
||||
* exact ratio that best matches a given room's score scale remains an on-device
|
||||
* tuning parameter — this removes the logic bug (no hysteresis at all). */
|
||||
#define EDGE_PRESENCE_HYST_RATIO 0.5f /**< Low thresh = HYST_RATIO * high thresh. */
|
||||
#define EDGE_PRESENCE_CLEAR_FRAMES 5 /**< Frames below low thresh before clearing. */
|
||||
|
||||
/* ---- DSP task tuning ---- */
|
||||
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user