mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
fix(server): resolve adversarial review findings C1-C5, H1-H3, H5, M1-M2
Critical fixes: - C1: FieldModel created with n_links=1 (single_link_config) so feed_calibration/extract_perturbation no longer get DimensionMismatch - C2: variance_explained now uses centered covariance trace (E[x²]-E[x]²) matching mode_energies normalization - C3: MP ratio uses total_obs = frames * links for consistent threshold between calibration and runtime - C4: Noise estimator filters to positive eigenvalues only, preventing collapse to ~0 on rank-deficient matrices (p > n) - C5: ESP32 paths gate total_persons on presence — empty room reports 0 High fixes: - H1: Bounding box computed from observed keypoints only (confidence > 0), preventing collapse from centroid-filled unobserved slots - H2: fuse_or_fallback returns Option<usize> instead of sentinel 0, eliminating type ambiguity between "fusion succeeded" and "zero people" - H3: Monotonic epoch-relative timestamps replace wall-clock/Instant mixing, preventing spurious TimestampMismatch on NTP steps - H5: ndarray-linalg gated behind "eigenvalue" feature flag (default=on), diagonal fallback used with --no-default-features Moderate fixes: - M1: calibration_start guards against replacing Fresh calibration - M2: parse_node_positions logs warning for malformed entries Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
74e0ebbd41
commit
a23bd2ec01
6 changed files with 180 additions and 92 deletions
|
|
@ -7,7 +7,7 @@
|
||||||
//! score-based heuristic in `score_to_person_count`.
|
//! score-based heuristic in `score_to_person_count`.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use wifi_densepose_signal::ruvsense::field_model::{CalibrationStatus, FieldModel};
|
use wifi_densepose_signal::ruvsense::field_model::{CalibrationStatus, FieldModel, FieldModelConfig};
|
||||||
|
|
||||||
use super::score_to_person_count;
|
use super::score_to_person_count;
|
||||||
|
|
||||||
|
|
@ -19,12 +19,21 @@ const ENERGY_THRESH_2: f64 = 12.0;
|
||||||
/// Perturbation energy threshold for detecting a third person.
|
/// Perturbation energy threshold for detecting a third person.
|
||||||
const ENERGY_THRESH_3: f64 = 25.0;
|
const ENERGY_THRESH_3: f64 = 25.0;
|
||||||
|
|
||||||
|
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
|
||||||
|
/// This avoids the DimensionMismatch error when feeding single-frame observations.
|
||||||
|
pub fn single_link_config() -> FieldModelConfig {
|
||||||
|
FieldModelConfig {
|
||||||
|
n_links: 1,
|
||||||
|
..FieldModelConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Estimate occupancy using the FieldModel when calibrated, falling back
|
/// Estimate occupancy using the FieldModel when calibrated, falling back
|
||||||
/// to the score-based heuristic otherwise.
|
/// to the score-based heuristic otherwise.
|
||||||
///
|
///
|
||||||
/// When the field model is `Fresh` or `Stale`, we extract body perturbation
|
/// Prefers `estimate_occupancy()` (eigenvalue-based) when the model is
|
||||||
/// from the most recent frames and map total energy to a person count.
|
/// calibrated and enough frames are available. Falls back to perturbation
|
||||||
/// On any error or when uncalibrated, we fall through to `score_to_person_count`.
|
/// energy thresholds, then to the score heuristic.
|
||||||
pub fn occupancy_or_fallback(
|
pub fn occupancy_or_fallback(
|
||||||
field: &FieldModel,
|
field: &FieldModel,
|
||||||
frame_history: &VecDeque<Vec<f64>>,
|
frame_history: &VecDeque<Vec<f64>>,
|
||||||
|
|
@ -44,9 +53,14 @@ pub fn occupancy_or_fallback(
|
||||||
return score_to_person_count(smoothed_score, prev_count);
|
return score_to_person_count(smoothed_score, prev_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the most recent frame as the observation for perturbation
|
// Try eigenvalue-based occupancy first (best accuracy).
|
||||||
// extraction. The FieldModel expects [n_links][n_subcarriers],
|
match field.estimate_occupancy(&frames) {
|
||||||
// so we wrap the single frame as a single-link observation.
|
Ok(count) => return count,
|
||||||
|
Err(_) => {} // fall through to perturbation energy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: perturbation energy thresholds.
|
||||||
|
// FieldModel expects [n_links][n_subcarriers] — we use n_links=1.
|
||||||
let observation = vec![frames[0].clone()];
|
let observation = vec![frames[0].clone()];
|
||||||
match field.extract_perturbation(&observation) {
|
match field.extract_perturbation(&observation) {
|
||||||
Ok(perturbation) => {
|
Ok(perturbation) => {
|
||||||
|
|
@ -54,14 +68,13 @@ pub fn occupancy_or_fallback(
|
||||||
3
|
3
|
||||||
} else if perturbation.total_energy > ENERGY_THRESH_2 {
|
} else if perturbation.total_energy > ENERGY_THRESH_2 {
|
||||||
2
|
2
|
||||||
} else {
|
} else if perturbation.total_energy > 1.0 {
|
||||||
1
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(_) => score_to_person_count(smoothed_score, prev_count),
|
||||||
tracing::warn!("FieldModel perturbation failed, using fallback: {e}");
|
|
||||||
score_to_person_count(smoothed_score, prev_count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => score_to_person_count(smoothed_score, prev_count),
|
_ => score_to_person_count(smoothed_score, prev_count),
|
||||||
|
|
@ -71,15 +84,16 @@ pub fn occupancy_or_fallback(
|
||||||
/// Feed the latest frame to the FieldModel during calibration collection.
|
/// Feed the latest frame to the FieldModel during calibration collection.
|
||||||
///
|
///
|
||||||
/// Only acts when the model status is `Collecting`. Wraps the latest frame
|
/// Only acts when the model status is `Collecting`. Wraps the latest frame
|
||||||
/// as a single-link observation and feeds it; errors are logged and ignored.
|
/// as a single-link observation (n_links=1) and feeds it.
|
||||||
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
|
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
|
||||||
if field.status() != CalibrationStatus::Collecting {
|
if field.status() != CalibrationStatus::Collecting {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(latest) = frame_history.back() {
|
if let Some(latest) = frame_history.back() {
|
||||||
|
// Single-link observation: [1][n_subcarriers]
|
||||||
let observations = vec![latest.clone()];
|
let observations = vec![latest.clone()];
|
||||||
if let Err(e) = field.feed_calibration(&observations) {
|
if let Err(e) = field.feed_calibration(&observations) {
|
||||||
tracing::warn!("FieldModel calibration feed error: {e}");
|
tracing::debug!("FieldModel calibration feed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,22 +101,27 @@ pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<V
|
||||||
/// Parse node positions from a semicolon-delimited string.
|
/// Parse node positions from a semicolon-delimited string.
|
||||||
///
|
///
|
||||||
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
|
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
|
||||||
/// Entries that fail to parse are silently skipped.
|
/// Malformed entries are skipped with a warning log.
|
||||||
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
|
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
input
|
input
|
||||||
.split(';')
|
.split(';')
|
||||||
.filter_map(|triplet| {
|
.enumerate()
|
||||||
|
.filter_map(|(idx, triplet)| {
|
||||||
let parts: Vec<&str> = triplet.split(',').collect();
|
let parts: Vec<&str> = triplet.split(',').collect();
|
||||||
if parts.len() != 3 {
|
if parts.len() != 3 {
|
||||||
|
tracing::warn!("Skipping malformed node position entry {idx}: '{triplet}' (expected x,y,z)");
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let x = parts[0].parse::<f32>().ok()?;
|
match (parts[0].parse::<f32>(), parts[1].parse::<f32>(), parts[2].parse::<f32>()) {
|
||||||
let y = parts[1].parse::<f32>().ok()?;
|
(Ok(x), Ok(y), Ok(z)) => Some([x, y, z]),
|
||||||
let z = parts[2].parse::<f32>().ok()?;
|
_ => {
|
||||||
Some([x, y, z])
|
tracing::warn!("Skipping unparseable node position entry {idx}: '{triplet}'");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output;
|
||||||
// Accuracy sprint: Kalman tracker, multistatic fusion, field model
|
// Accuracy sprint: Kalman tracker, multistatic fusion, field model
|
||||||
use wifi_densepose_signal::ruvsense::pose_tracker::PoseTracker;
|
use wifi_densepose_signal::ruvsense::pose_tracker::PoseTracker;
|
||||||
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticFuser, MultistaticConfig};
|
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticFuser, MultistaticConfig};
|
||||||
use wifi_densepose_signal::ruvsense::field_model::{FieldModel, FieldModelConfig, CalibrationStatus};
|
use wifi_densepose_signal::ruvsense::field_model::{FieldModel, CalibrationStatus};
|
||||||
|
|
||||||
// ── CLI ──────────────────────────────────────────────────────────────────────
|
// ── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -2844,17 +2844,26 @@ async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::V
|
||||||
|
|
||||||
async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||||
let mut s = state.write().await;
|
let mut s = state.write().await;
|
||||||
// Guard: don't discard an in-progress calibration
|
// Guard: don't discard an in-progress or fresh calibration
|
||||||
if let Some(ref fm) = s.field_model {
|
if let Some(ref fm) = s.field_model {
|
||||||
if fm.status() == CalibrationStatus::Collecting {
|
match fm.status() {
|
||||||
return Json(serde_json::json!({
|
CalibrationStatus::Collecting => {
|
||||||
"success": false,
|
return Json(serde_json::json!({
|
||||||
"error": "Calibration already in progress. Call /calibration/stop first.",
|
"success": false,
|
||||||
"frame_count": fm.calibration_frame_count(),
|
"error": "Calibration already in progress. Call /calibration/stop first.",
|
||||||
}));
|
"frame_count": fm.calibration_frame_count(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
CalibrationStatus::Fresh => {
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"success": false,
|
||||||
|
"error": "A fresh calibration already exists. Call /calibration/stop or wait for expiry.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
_ => {} // Stale/Expired/Uncalibrated — ok to recalibrate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match FieldModel::new(FieldModelConfig::default()) {
|
match FieldModel::new(field_bridge::single_link_config()) {
|
||||||
Ok(fm) => {
|
Ok(fm) => {
|
||||||
s.field_model = Some(fm);
|
s.field_model = Some(fm);
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
|
|
@ -3178,9 +3187,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
else if vitals.presence { 0.3 }
|
else if vitals.presence { 0.3 }
|
||||||
else { 0.05 };
|
else { 0.05 };
|
||||||
|
|
||||||
// Aggregate person count: attention-weighted fusion or max-per-node fallback.
|
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
let total_persons = {
|
let total_persons = if vitals.presence {
|
||||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||||
&s.multistatic_fuser, &s.node_states,
|
&s.multistatic_fuser, &s.node_states,
|
||||||
);
|
);
|
||||||
|
|
@ -3190,10 +3199,13 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
||||||
let count = s.person_count();
|
let count = s.person_count();
|
||||||
s.prev_person_count = count;
|
s.prev_person_count = count;
|
||||||
count
|
count.max(1) // presence=true => at least 1
|
||||||
}
|
}
|
||||||
None => fallback_count,
|
None => fallback_count.unwrap_or(0).max(1),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.prev_person_count = 0;
|
||||||
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Feed field model calibration if active (use per-node history for ESP32).
|
// Feed field model calibration if active (use per-node history for ESP32).
|
||||||
|
|
@ -3398,9 +3410,9 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
else if classification.motion_level == "present_still" { 0.3 }
|
else if classification.motion_level == "present_still" { 0.3 }
|
||||||
else { 0.05 };
|
else { 0.05 };
|
||||||
|
|
||||||
// Aggregate person count: attention-weighted fusion or naive sum fallback.
|
// Aggregate person count: gate on presence first (matching WiFi path).
|
||||||
let now = std::time::Instant::now();
|
let now = std::time::Instant::now();
|
||||||
let total_persons = {
|
let total_persons = if classification.presence {
|
||||||
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback(
|
||||||
&s.multistatic_fuser, &s.node_states,
|
&s.multistatic_fuser, &s.node_states,
|
||||||
);
|
);
|
||||||
|
|
@ -3410,10 +3422,13 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10;
|
||||||
let count = s.person_count();
|
let count = s.person_count();
|
||||||
s.prev_person_count = count;
|
s.prev_person_count = count;
|
||||||
count
|
count.max(1)
|
||||||
}
|
}
|
||||||
None => fallback_count,
|
None => fallback_count.unwrap_or(0).max(1),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
s.prev_person_count = 0;
|
||||||
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Feed field model calibration if active (use per-node history for ESP32).
|
// Feed field model calibration if active (use per-node history for ESP32).
|
||||||
|
|
@ -4239,7 +4254,7 @@ async fn main() {
|
||||||
},
|
},
|
||||||
field_model: if args.calibrate {
|
field_model: if args.calibrate {
|
||||||
info!("Field model calibration enabled — room should be empty during startup");
|
info!("Field model calibration enabled — room should be empty during startup");
|
||||||
FieldModel::new(FieldModelConfig::default()).ok()
|
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
//! (e.g. insufficient nodes or timestamp spread).
|
//! (e.g. insufficient nodes or timestamp spread).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::LazyLock;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
|
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
|
||||||
|
|
@ -21,6 +22,10 @@ const STALE_THRESHOLD: Duration = Duration::from_secs(10);
|
||||||
/// Default WiFi channel frequency (MHz) used for single-channel frames.
|
/// Default WiFi channel frequency (MHz) used for single-channel frames.
|
||||||
const DEFAULT_FREQ_MHZ: u32 = 2437; // Channel 6
|
const DEFAULT_FREQ_MHZ: u32 = 2437; // Channel 6
|
||||||
|
|
||||||
|
/// Monotonic reference point for timestamp generation. All node timestamps
|
||||||
|
/// are relative to this instant, avoiding wall-clock/monotonic mixing issues.
|
||||||
|
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
/// Convert a single `NodeState` into a `MultiBandCsiFrame` suitable for
|
/// Convert a single `NodeState` into a `MultiBandCsiFrame` suitable for
|
||||||
/// multistatic fusion.
|
/// multistatic fusion.
|
||||||
///
|
///
|
||||||
|
|
@ -37,14 +42,10 @@ pub fn node_frame_from_state(node_id: u8, ns: &NodeState) -> Option<MultiBandCsi
|
||||||
let n_sub = amplitude.len();
|
let n_sub = amplitude.len();
|
||||||
let phase = vec![0.0_f32; n_sub];
|
let phase = vec![0.0_f32; n_sub];
|
||||||
|
|
||||||
// Derive a monotonic timestamp: use wall-clock time minus elapsed since
|
// Monotonic timestamp: microseconds since a shared process-local epoch.
|
||||||
// last frame to approximate when the frame was actually received.
|
// All nodes use the same reference so the fuser's guard_interval_us check
|
||||||
let wall_us = std::time::SystemTime::now()
|
// compares apples to apples. No wall-clock mixing (immune to NTP jumps).
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let timestamp_us = last_time.duration_since(*EPOCH).as_micros() as u64;
|
||||||
.map(|d| d.as_micros() as u64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let age_us = last_time.elapsed().as_micros() as u64;
|
|
||||||
let timestamp_us = wall_us.saturating_sub(age_us);
|
|
||||||
|
|
||||||
let canonical = CanonicalCsiFrame {
|
let canonical = CanonicalCsiFrame {
|
||||||
amplitude,
|
amplitude,
|
||||||
|
|
@ -89,23 +90,23 @@ pub fn node_frames_from_states(node_states: &HashMap<u8, NodeState>) -> Vec<Mult
|
||||||
|
|
||||||
/// Attempt multistatic fusion; fall back to max per-node person count on failure.
|
/// Attempt multistatic fusion; fall back to max per-node person count on failure.
|
||||||
///
|
///
|
||||||
/// Returns `(fused_frame, fallback_person_count)`. When fusion succeeds, the
|
/// Returns `(fused_frame, fallback_person_count)`. When fusion succeeds,
|
||||||
/// caller should compute person count from the fused amplitudes (the returned
|
/// `fallback_person_count` is `None` — the caller must compute count from
|
||||||
/// fallback count is 0 as a sentinel). On failure, returns the maximum
|
/// the fused amplitudes. On failure, returns the maximum per-node count
|
||||||
/// per-node count (not the sum, to avoid double-counting overlapping coverage).
|
/// (not the sum, to avoid double-counting overlapping coverage).
|
||||||
pub fn fuse_or_fallback(
|
pub fn fuse_or_fallback(
|
||||||
fuser: &MultistaticFuser,
|
fuser: &MultistaticFuser,
|
||||||
node_states: &HashMap<u8, NodeState>,
|
node_states: &HashMap<u8, NodeState>,
|
||||||
) -> (Option<FusedSensingFrame>, usize) {
|
) -> (Option<FusedSensingFrame>, Option<usize>) {
|
||||||
let frames = node_frames_from_states(node_states);
|
let frames = node_frames_from_states(node_states);
|
||||||
if frames.is_empty() {
|
if frames.is_empty() {
|
||||||
return (None, 0);
|
return (None, Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
match fuser.fuse(&frames) {
|
match fuser.fuse(&frames) {
|
||||||
Ok(fused) => {
|
Ok(fused) => {
|
||||||
// Return 0 as sentinel — caller must compute count from fused amplitudes.
|
// Caller must compute person count from fused amplitudes.
|
||||||
(Some(fused), 0)
|
(Some(fused), None)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::debug!("Multistatic fusion failed ({e}), using per-node max fallback");
|
tracing::debug!("Multistatic fusion failed ({e}), using per-node max fallback");
|
||||||
|
|
@ -120,7 +121,7 @@ pub fn fuse_or_fallback(
|
||||||
.map(|ns| ns.prev_person_count)
|
.map(|ns| ns.prev_person_count)
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
(None, max_count)
|
(None, Some(max_count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +259,6 @@ mod tests {
|
||||||
let states: HashMap<u8, NodeState> = HashMap::new();
|
let states: HashMap<u8, NodeState> = HashMap::new();
|
||||||
let (fused, count) = fuse_or_fallback(&fuser, &states);
|
let (fused, count) = fuse_or_fallback(&fuser, &states);
|
||||||
assert!(fused.is_none());
|
assert!(fused.is_none());
|
||||||
assert_eq!(count, 0);
|
assert_eq!(count, Some(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,23 +123,35 @@ pub fn tracker_to_person_detections(tracker: &PoseTracker) -> Vec<PersonDetectio
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Compute bounding box from keypoint min/max
|
// Compute bounding box from observed keypoints only (confidence > 0).
|
||||||
|
// Unobserved slots (centroid-filled) collapse the bbox over time.
|
||||||
let mut min_x = f64::MAX;
|
let mut min_x = f64::MAX;
|
||||||
let mut min_y = f64::MAX;
|
let mut min_y = f64::MAX;
|
||||||
let mut max_x = f64::MIN;
|
let mut max_x = f64::MIN;
|
||||||
let mut max_y = f64::MIN;
|
let mut max_y = f64::MIN;
|
||||||
|
let mut observed = 0;
|
||||||
for kp in &keypoints {
|
for kp in &keypoints {
|
||||||
if kp.x < min_x { min_x = kp.x; }
|
if kp.confidence > 0.0 {
|
||||||
if kp.y < min_y { min_y = kp.y; }
|
if kp.x < min_x { min_x = kp.x; }
|
||||||
if kp.x > max_x { max_x = kp.x; }
|
if kp.y < min_y { min_y = kp.y; }
|
||||||
if kp.y > max_y { max_y = kp.y; }
|
if kp.x > max_x { max_x = kp.x; }
|
||||||
|
if kp.y > max_y { max_y = kp.y; }
|
||||||
|
observed += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bbox = BoundingBox {
|
let bbox = if observed > 0 {
|
||||||
x: min_x,
|
BoundingBox {
|
||||||
y: min_y,
|
x: min_x,
|
||||||
width: max_x - min_x,
|
y: min_y,
|
||||||
height: max_y - min_y,
|
width: (max_x - min_x).max(0.01),
|
||||||
|
height: (max_y - min_y).max(0.01),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No observed keypoints — use a default bbox at centroid
|
||||||
|
let cx = keypoints.iter().map(|k| k.x).sum::<f64>() / keypoints.len() as f64;
|
||||||
|
let cy = keypoints.iter().map(|k| k.y).sum::<f64>() / keypoints.len() as f64;
|
||||||
|
BoundingBox { x: cx - 0.3, y: cy - 0.5, width: 0.6, height: 1.0 }
|
||||||
};
|
};
|
||||||
|
|
||||||
PersonDetection {
|
PersonDetection {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ keywords = ["wifi", "csi", "signal-processing", "densepose", "rust"]
|
||||||
categories = ["science", "computer-vision"]
|
categories = ["science", "computer-vision"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["eigenvalue"]
|
||||||
|
## Enable eigenvalue-based person counting (requires BLAS via ndarray-linalg).
|
||||||
|
## Disable with --no-default-features to use the diagonal fallback instead.
|
||||||
|
eigenvalue = ["ndarray-linalg"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core utilities
|
# Core utilities
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
@ -20,7 +26,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
|
||||||
# Signal processing
|
# Signal processing
|
||||||
ndarray = { workspace = true }
|
ndarray = { workspace = true }
|
||||||
ndarray-linalg = { workspace = true }
|
ndarray-linalg = { workspace = true, optional = true }
|
||||||
rustfft.workspace = true
|
rustfft.workspace = true
|
||||||
num-complex.workspace = true
|
num-complex.workspace = true
|
||||||
num-traits.workspace = true
|
num-traits.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@
|
||||||
//! - ADR-030: RuvSense Persistent Field Model
|
//! - ADR-030: RuvSense Persistent Field Model
|
||||||
|
|
||||||
use ndarray::Array2;
|
use ndarray::Array2;
|
||||||
|
#[cfg(feature = "eigenvalue")]
|
||||||
use ndarray_linalg::Eigh;
|
use ndarray_linalg::Eigh;
|
||||||
|
#[cfg(feature = "eigenvalue")]
|
||||||
use ndarray_linalg::UPLO;
|
use ndarray_linalg::UPLO;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -525,7 +527,8 @@ impl FieldModel {
|
||||||
let bessel = total_obs / (total_obs - 1.0);
|
let bessel = total_obs / (total_obs - 1.0);
|
||||||
covariance *= bessel;
|
covariance *= bessel;
|
||||||
|
|
||||||
// Symmetric eigendecomposition
|
// Symmetric eigendecomposition (requires eigenvalue feature / BLAS)
|
||||||
|
#[cfg(feature = "eigenvalue")]
|
||||||
match covariance.eigh(UPLO::Upper) {
|
match covariance.eigh(UPLO::Upper) {
|
||||||
Ok((eigenvalues, eigenvectors)) => {
|
Ok((eigenvalues, eigenvectors)) => {
|
||||||
// eigenvalues are in ascending order from ndarray-linalg
|
// eigenvalues are in ascending order from ndarray-linalg
|
||||||
|
|
@ -550,21 +553,25 @@ impl FieldModel {
|
||||||
.map(|&idx| eigenvalues[idx].max(0.0))
|
.map(|&idx| eigenvalues[idx].max(0.0))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Marcenko-Pastur threshold for baseline eigenvalue count.
|
// Marcenko-Pastur noise estimate: median of POSITIVE
|
||||||
// Use median of bottom half as robust noise estimate
|
// eigenvalues in the bottom half. Excludes zeros from
|
||||||
// (consistent with estimate_occupancy).
|
// rank-deficient matrices (when p > n).
|
||||||
let noise_var = {
|
let noise_var = {
|
||||||
let mut sorted_eigs: Vec<f64> = eigenvalues
|
let mut positive: Vec<f64> = eigenvalues
|
||||||
.iter().copied().map(|e| e.max(0.0)).collect();
|
.iter().copied().filter(|&e| e > 1e-10).collect();
|
||||||
sorted_eigs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
let half = sorted_eigs.len() / 2;
|
if positive.len() >= 4 {
|
||||||
if half > 0 {
|
let half = positive.len() / 2;
|
||||||
sorted_eigs[..half].iter().sum::<f64>() / half as f64
|
positive[..half].iter().sum::<f64>() / half as f64
|
||||||
|
} else if !positive.is_empty() {
|
||||||
|
positive[0]
|
||||||
} else {
|
} else {
|
||||||
sorted_eigs.iter().sum::<f64>() / sorted_eigs.len().max(1) as f64
|
1e-10
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let ratio = n_sc as f64 / self.covariance_count as f64;
|
// MP ratio: p/n where n = total observations (frames * links)
|
||||||
|
let total_obs_mp = self.covariance_count as f64 * self.config.n_links as f64;
|
||||||
|
let ratio = n_sc as f64 / total_obs_mp;
|
||||||
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
|
||||||
let baseline_count = eigenvalues
|
let baseline_count = eigenvalues
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -578,6 +585,9 @@ impl FieldModel {
|
||||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When eigenvalue feature is disabled, use diagonal fallback
|
||||||
|
#[cfg(not(feature = "eigenvalue"))]
|
||||||
|
{ diagonal_fallback(&self.link_stats, n_sc, n_modes) }
|
||||||
} else {
|
} else {
|
||||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||||
}
|
}
|
||||||
|
|
@ -585,14 +595,23 @@ impl FieldModel {
|
||||||
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
diagonal_fallback(&self.link_stats, n_sc, n_modes)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute variance explained
|
// Compute variance explained using the same centered covariance as modes.
|
||||||
|
// total_variance = trace(centered_covariance) = sum of ALL eigenvalues.
|
||||||
let total_energy: f64 = mode_energies.iter().sum();
|
let total_energy: f64 = mode_energies.iter().sum();
|
||||||
// For variance_explained, we need total variance across all subcarriers.
|
|
||||||
// Use the sum of all eigenvalues (== trace of covariance == total variance).
|
|
||||||
let total_variance = if let Some(ref cov_sum) = self.covariance_sum {
|
let total_variance = if let Some(ref cov_sum) = self.covariance_sum {
|
||||||
if self.covariance_count > 1 {
|
if self.covariance_count > 1 {
|
||||||
let scale = 1.0 / (self.covariance_count as f64 - 1.0);
|
let n_links_f = self.config.n_links as f64;
|
||||||
(0..n_sc).map(|i| (cov_sum[[i, i]] * scale).max(0.0)).sum::<f64>()
|
let total_obs = self.covariance_count as f64 * n_links_f;
|
||||||
|
// Centered trace: E[x^2] - E[x]^2, with Bessel correction
|
||||||
|
let mut avg_mean = vec![0.0f64; n_sc];
|
||||||
|
for ls in &self.link_stats {
|
||||||
|
let m = ls.mean_vector();
|
||||||
|
for i in 0..n_sc { avg_mean[i] += m[i]; }
|
||||||
|
}
|
||||||
|
for i in 0..n_sc { avg_mean[i] /= n_links_f; }
|
||||||
|
let raw_trace: f64 = (0..n_sc).map(|i| cov_sum[[i, i]] / total_obs).sum();
|
||||||
|
let mean_sq: f64 = avg_mean.iter().map(|m| m * m).sum();
|
||||||
|
(raw_trace - mean_sq).max(0.0) * total_obs / (total_obs - 1.0)
|
||||||
} else {
|
} else {
|
||||||
total_energy
|
total_energy
|
||||||
}
|
}
|
||||||
|
|
@ -699,6 +718,10 @@ impl FieldModel {
|
||||||
///
|
///
|
||||||
/// `recent_frames`: sliding window of amplitude vectors (recommend 50 frames
|
/// `recent_frames`: sliding window of amplitude vectors (recommend 50 frames
|
||||||
/// ~ 2.5s at 20 Hz). Returns estimated person count (0 = empty room).
|
/// ~ 2.5s at 20 Hz). Returns estimated person count (0 = empty room).
|
||||||
|
///
|
||||||
|
/// Requires the `eigenvalue` feature (BLAS). Returns `NotCalibrated` when
|
||||||
|
/// the feature is disabled.
|
||||||
|
#[cfg(feature = "eigenvalue")]
|
||||||
pub fn estimate_occupancy(&self, recent_frames: &[Vec<f64>]) -> Result<usize, FieldModelError> {
|
pub fn estimate_occupancy(&self, recent_frames: &[Vec<f64>]) -> Result<usize, FieldModelError> {
|
||||||
let modes = self.modes.as_ref().ok_or(FieldModelError::NotCalibrated)?;
|
let modes = self.modes.as_ref().ok_or(FieldModelError::NotCalibrated)?;
|
||||||
|
|
||||||
|
|
@ -752,16 +775,22 @@ impl FieldModel {
|
||||||
Err(_) => return Ok(0), // SVD failure = can't estimate
|
Err(_) => return Ok(0), // SVD failure = can't estimate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Marcenko-Pastur noise threshold
|
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
|
||||||
|
// in the bottom half. Excludes zeros from rank-deficient matrices
|
||||||
|
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
|
||||||
let noise_var = {
|
let noise_var = {
|
||||||
let mut sorted: Vec<f64> = eigenvalues.iter().copied().collect();
|
let mut positive: Vec<f64> = eigenvalues.iter()
|
||||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
.copied()
|
||||||
// Median of bottom half as robust noise estimate
|
.filter(|&e| e > 1e-10)
|
||||||
let half = sorted.len() / 2;
|
.collect();
|
||||||
if half > 0 {
|
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
sorted[..half].iter().sum::<f64>() / half as f64
|
if positive.len() >= 4 {
|
||||||
|
let half = positive.len() / 2;
|
||||||
|
positive[..half].iter().sum::<f64>() / half as f64
|
||||||
|
} else if !positive.is_empty() {
|
||||||
|
positive[0]
|
||||||
} else {
|
} else {
|
||||||
1.0
|
return Ok(0); // All zero eigenvalues — can't estimate
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let ratio = n as f64 / count as f64;
|
let ratio = n as f64 / count as f64;
|
||||||
|
|
@ -773,6 +802,12 @@ impl FieldModel {
|
||||||
Ok(occupancy.min(10)) // Cap at 10 persons
|
Ok(occupancy.min(10)) // Cap at 10 persons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stub when eigenvalue feature is disabled — always returns NotCalibrated.
|
||||||
|
#[cfg(not(feature = "eigenvalue"))]
|
||||||
|
pub fn estimate_occupancy(&self, _recent_frames: &[Vec<f64>]) -> Result<usize, FieldModelError> {
|
||||||
|
Err(FieldModelError::NotCalibrated)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check calibration freshness against a given timestamp.
|
/// Check calibration freshness against a given timestamp.
|
||||||
pub fn check_freshness(&self, current_us: u64) -> CalibrationStatus {
|
pub fn check_freshness(&self, current_us: u64) -> CalibrationStatus {
|
||||||
if self.modes.is_none() {
|
if self.modes.is_none() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue