mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
0c2b1c16cc
* 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>
242 lines
9.4 KiB
Rust
242 lines
9.4 KiB
Rust
//! 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);
|
||
}
|
||
}
|