Files
ruvnet--RuView/v2/crates/wifi-densepose-sensing-server/src/field_localize.rs
T
rUv 0c2b1c16cc 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>
2026-06-14 00:31:30 -04:00

242 lines
9.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Field-peak localization for the Observatory 3D view (issue #1050).
//!
//! ## What this is (and is not)
//!
//! The `/ws/sensing` `sensing_update` frame already carries a real `signal_field`
//! — a 20×20 grid built by `generate_signal_field()` from **measured subcarrier
//! variances** weighted by the **measured motion-band power**. The grid's hot
//! cells are the strongest scatterers in that field representation; as the CSI
//! changes (a person moving through the link), the peak cell moves with it.
//!
//! This module reads the **strongest peak(s)** out of that real field and maps
//! the peak cell to the Observatory room's world coordinates. That gives the
//! 3D figure a position + motion magnitude that are **derived from real signal
//! data**, so the figure now tracks where the field energy concentrates.
//!
//! ### Honesty caveat (do not over-claim)
//!
//! The field's subcarrier→angle mapping in `generate_signal_field()` is a
//! *representation*, not calibrated multistatic triangulation in metric room
//! coordinates. A single ESP32 link cannot resolve a true (x, z) room position.
//! So the emitted `position` is **"strongest field peak in the room model"**,
//! not survey-grade localization. It is real (a function of live CSI), it moves
//! with real motion, and it is honest about its source — but it is NOT a
//! calibrated person fix. Per-person skeletal `pose` keypoints in room
//! coordinates remain gated on the pose model + paired ground-truth data
//! (ADR-079), so `pose` here is only ever set from a real aggregate posture
//! estimate when one exists, and is `None` otherwise (never fabricated).
//!
//! ## Coordinate mapping
//!
//! The Observatory builds its field point cloud (see `ui/observatory/js/main.js`
//! `_buildSignalField`) as, for grid cell `(ix, iz)` of a `20×20` grid:
//!
//! ```text
//! world_x = (ix - gridSize/2) * 0.6
//! world_z = (iz - gridSize/2) * 0.5
//! world_y = 0 (floor)
//! ```
//!
//! and indexes the field as `idx = iz * gridSize + ix` — identical to the
//! server's `generate_signal_field()` layout (`values[z * grid + x]`). We map
//! the peak cell with the **same** transform so the figure lands exactly on the
//! field hotspot it is standing on.
/// World-space scale factor for the X (width) axis, matching the Observatory's
/// `_buildSignalField`: `world_x = (ix - nx/2) * X_SCALE`.
pub const X_SCALE: f64 = 0.6;
/// World-space scale factor for the Z (depth) axis, matching the Observatory's
/// `_buildSignalField`: `world_z = (iz - nz/2) * Z_SCALE`.
pub const Z_SCALE: f64 = 0.5;
/// Minimum normalized field value (`signal_field.values` are normalized to
/// `[0, 1]`) for a cell to be considered a real peak rather than background
/// attenuation. Below this we treat the field as having no localizable hotspot.
pub const PEAK_THRESHOLD: f64 = 0.35;
/// A localized field peak in Observatory world coordinates.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldPeak {
/// World position `[x, y, z]` in Observatory scene units (meters). `y` is
/// always `0.0` — the field is a floor-plane grid with no height info.
pub position: [f64; 3],
/// Normalized field intensity at the peak cell, in `[0, 1]`.
pub intensity: f64,
/// Source grid cell `(ix, iz)` the peak was read from (for tests/debug).
pub cell: (usize, usize),
}
/// Map a grid cell `(ix, iz)` of an `nx × nz` field to Observatory world
/// coordinates, matching `ui/observatory/js/main.js::_buildSignalField`.
#[must_use]
pub fn cell_to_world(ix: usize, iz: usize, nx: usize, nz: usize) -> [f64; 3] {
let wx = (ix as f64 - nx as f64 / 2.0) * X_SCALE;
let wz = (iz as f64 - nz as f64 / 2.0) * Z_SCALE;
[wx, 0.0, wz]
}
/// Extract up to `max_peaks` strongest, spatially-separated peaks from a
/// `signal_field` grid.
///
/// * `values` — row-major field grid, `values[iz * nx + ix]`, normalized to
/// `[0, 1]` (as produced by `generate_signal_field`).
/// * `nx`, `nz` — grid dimensions (the field's `grid_size` is `[nx, 1, nz]`).
/// * `max_peaks` — how many person positions to extract (≥ 1).
///
/// Returns peaks sorted strongest-first. Each successive peak is forced to be
/// at least `min_separation_cells` away from all previously selected peaks so
/// two persons don't collapse onto the same hotspot. Returns an **empty**
/// vector when no cell exceeds [`PEAK_THRESHOLD`] — an empty / no-presence
/// field yields no phantom person.
#[must_use]
pub fn extract_peaks(
values: &[f64],
nx: usize,
nz: usize,
max_peaks: usize,
min_separation_cells: f64,
) -> Vec<FieldPeak> {
if nx == 0 || nz == 0 || values.len() < nx * nz || max_peaks == 0 {
return Vec::new();
}
// Collect all cells above threshold, strongest first.
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
for iz in 0..nz {
for ix in 0..nx {
let v = values[iz * nx + ix];
if v >= PEAK_THRESHOLD {
candidates.push((ix, iz, v));
}
}
}
candidates.sort_by(|a, b| b.2.total_cmp(&a.2));
let mut peaks: Vec<FieldPeak> = Vec::new();
for (ix, iz, v) in candidates {
if peaks.len() >= max_peaks {
break;
}
// Enforce spatial separation from already-chosen peaks (in cell units).
let too_close = peaks.iter().any(|p| {
let dx = p.cell.0 as f64 - ix as f64;
let dz = p.cell.1 as f64 - iz as f64;
(dx * dx + dz * dz).sqrt() < min_separation_cells
});
if too_close {
continue;
}
peaks.push(FieldPeak {
position: cell_to_world(ix, iz, nx, nz),
intensity: v,
cell: (ix, iz),
});
}
peaks
}
/// Convert measured `motion_band_power` to the `motion_score` scale the
/// Observatory UI expects.
///
/// The UI compares `motion_score > 50` to switch between calm and energetic
/// emission (see `_updateDotMatrixMist` / `_updateParticleTrail`). The raw
/// `motion_band_power` is already in roughly that band for live ESP32 data
/// (the issue reports `motion_band_power: 63.3` while moving), so we pass it
/// through directly, clamped to a sane `[0, 100]` display range. This keeps the
/// emitted value a **direct, real** function of measured motion energy rather
/// than a re-scaled invention.
#[must_use]
pub fn motion_score_from_power(motion_band_power: f64) -> f64 {
motion_band_power.clamp(0.0, 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_to_world_matches_observatory_layout() {
// Center cell of a 20×20 grid maps near origin.
let c = cell_to_world(10, 10, 20, 20);
assert!((c[0] - 0.0).abs() < 1e-9);
assert_eq!(c[1], 0.0);
assert!((c[2] - 0.0).abs() < 1e-9);
// Corner cell (0,0) maps to the room's near-left corner.
let corner = cell_to_world(0, 0, 20, 20);
assert!((corner[0] - (-6.0)).abs() < 1e-9); // (0-10)*0.6
assert!((corner[2] - (-5.0)).abs() < 1e-9); // (0-10)*0.5
}
#[test]
fn extract_peaks_finds_known_hotspot() {
// 20×20 field, all background, single strong peak at cell (15, 4).
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
let peak_ix = 15;
let peak_iz = 4;
values[peak_iz * nx + peak_ix] = 1.0;
let peaks = extract_peaks(&values, nx, nz, 1, 3.0);
assert_eq!(peaks.len(), 1);
assert_eq!(peaks[0].cell, (peak_ix, peak_iz));
// Position must match the Observatory cell→world transform within tol.
let expected = cell_to_world(peak_ix, peak_iz, nx, nz);
assert!((peaks[0].position[0] - expected[0]).abs() < 1e-9);
assert!((peaks[0].position[2] - expected[2]).abs() < 1e-9);
// Sanity: (15-10)*0.6 = 3.0, (4-10)*0.5 = -3.0
assert!((peaks[0].position[0] - 3.0).abs() < 1e-9);
assert!((peaks[0].position[2] - (-3.0)).abs() < 1e-9);
}
#[test]
fn empty_field_yields_no_peaks() {
let nx = 20;
let nz = 20;
// All cells below PEAK_THRESHOLD — no presence.
let values = vec![0.10; nx * nz];
let peaks = extract_peaks(&values, nx, nz, 3, 3.0);
assert!(
peaks.is_empty(),
"below-threshold field must not produce a phantom peak"
);
}
#[test]
fn two_separated_peaks_do_not_collapse() {
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
values[2 * nx + 3] = 0.95; // peak A at (3, 2)
values[15 * nx + 17] = 0.90; // peak B at (17, 15)
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
assert_eq!(peaks.len(), 2);
// Strongest first.
assert_eq!(peaks[0].cell, (3, 2));
assert_eq!(peaks[1].cell, (17, 15));
}
#[test]
fn nearby_secondary_peak_is_suppressed() {
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
values[10 * nx + 10] = 1.00; // primary
values[10 * nx + 11] = 0.99; // adjacent — should be suppressed (sep 3.0)
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
assert_eq!(peaks.len(), 1, "adjacent cell must not become a 2nd person");
assert_eq!(peaks[0].cell, (10, 10));
}
#[test]
fn motion_score_passthrough_and_clamp() {
assert!((motion_score_from_power(63.3) - 63.3).abs() < 1e-9);
assert_eq!(motion_score_from_power(-5.0), 0.0);
assert_eq!(motion_score_from_power(250.0), 100.0);
}
}