feat: DynamicMinCut person separation + UI lerp smoothing

- Added ruvector-mincut dependency to sensing server
- Replaced variance-based person scoring with actual graph min-cut on
  subcarrier temporal correlation matrix (Pearson correlation edges,
  DynamicMinCut exact max-flow)
- Recalibrated feature scaling for real ESP32 data ranges
- UI: client-side lerp interpolation (alpha=0.25) on keypoint positions
- Dampened procedural animation (noise, stride, extremity jitter)
- Person count thresholds retuned for mincut ratio

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-30 16:39:05 -04:00
parent 3f549f4d25
commit 10d69c1071
2 changed files with 141 additions and 24 deletions

View File

@ -43,5 +43,8 @@ clap = { workspace = true }
# Multi-BSSID WiFi scanning pipeline (ADR-022 Phase 3)
wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifiscan" }
# RuVector graph min-cut for person separation (ADR-068)
ruvector-mincut = { workspace = true }
[dev-dependencies]
tempfile = "3.10"

View File

@ -17,6 +17,7 @@ mod vital_signs;
use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding};
use std::collections::{HashMap, VecDeque};
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
@ -2054,27 +2055,137 @@ fn fuse_multi_node_features(
/// Returns a raw score (0.0..1.0) that the caller converts to person count
/// after temporal smoothing.
fn compute_person_score(feat: &FeatureInfo) -> f64 {
// Normalize each feature to [0, 1] using calibrated ranges:
//
// variance: intra-frame amp variance. 1-person ~2-15, 2-person ~15-60,
// real ESP32 can go higher. Use 30.0 as scaling midpoint.
let var_norm = (feat.variance / 30.0).clamp(0.0, 1.0);
// change_points: threshold crossings in 56 subcarriers. 1-person ~5-15,
// 2-person ~15-30. Scale by 30.0 (half of max 55).
// Normalize each feature to [0, 1] using ranges calibrated from real
// ESP32 hardware (COM6/COM9 on ruv.net, March 2026).
let var_norm = (feat.variance / 300.0).clamp(0.0, 1.0);
let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0);
// motion_band_power: upper-half subcarrier variance. 1-person ~1-8,
// 2-person ~8-25. Scale by 20.0.
let motion_norm = (feat.motion_band_power / 20.0).clamp(0.0, 1.0);
// spectral_power: mean squared amplitude. Highly variable (~100-1000+).
// Use relative change indicator: high spectral_power with high variance
// suggests multiple reflectors. Scale by 500.0.
let motion_norm = (feat.motion_band_power / 250.0).clamp(0.0, 1.0);
let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0);
var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15
}
// Weighted composite — variance and change_points carry the most signal.
var_norm * 0.35 + cp_norm * 0.30 + motion_norm * 0.20 + sp_norm * 0.15
/// Estimate person count via ruvector DynamicMinCut on the subcarrier
/// temporal correlation graph.
///
/// Builds a graph where:
/// - Nodes = active subcarriers (variance > noise floor)
/// - Edges = Pearson correlation between subcarrier time series
/// (weight = correlation coefficient; high correlation = heavy edge)
/// - Source = virtual node connected to the most active subcarrier
/// - Sink = virtual node connected to the least correlated subcarrier
///
/// The min-cut value indicates how many independent motion clusters exist:
/// - High min-cut (relative to total edge weight) → one tightly coupled
/// group → 1 person
/// - Low min-cut → two loosely coupled groups → 2 persons
///
/// Uses `ruvector_mincut::DynamicMinCut` for O(V²E) exact max-flow.
fn estimate_persons_from_correlation(frame_history: &VecDeque<Vec<f64>>) -> usize {
let n_frames = frame_history.len();
if n_frames < 10 {
return 1;
}
let window: Vec<&Vec<f64>> = frame_history.iter().rev().take(20).collect();
let n_sub = window[0].len().min(56);
if n_sub < 4 {
return 1;
}
let k = window.len() as f64;
// Per-subcarrier mean and variance
let mut means = vec![0.0f64; n_sub];
let mut variances = vec![0.0f64; n_sub];
for frame in &window {
for sc in 0..n_sub.min(frame.len()) {
means[sc] += frame[sc] / k;
}
}
for frame in &window {
for sc in 0..n_sub.min(frame.len()) {
variances[sc] += (frame[sc] - means[sc]).powi(2) / k;
}
}
// Active subcarriers: variance above noise floor
let noise_floor = 1.0;
let active: Vec<usize> = (0..n_sub).filter(|&sc| variances[sc] > noise_floor).collect();
let m = active.len();
if m < 3 {
return if m == 0 { 0 } else { 1 };
}
// Build correlation graph edges between active subcarriers.
// Edge weight = |Pearson correlation|. High correlation → same person.
let mut edges: Vec<(u64, u64, f64)> = Vec::new();
let source = m as u64;
let sink = (m + 1) as u64;
// Precompute std devs
let stds: Vec<f64> = active.iter().map(|&sc| variances[sc].sqrt().max(1e-9)).collect();
for i in 0..m {
for j in (i + 1)..m {
// Pearson correlation between subcarriers i and j
let mut cov = 0.0f64;
for frame in &window {
let si = active[i];
let sj = active[j];
if si < frame.len() && sj < frame.len() {
cov += (frame[si] - means[si]) * (frame[sj] - means[sj]) / k;
}
}
let corr = (cov / (stds[i] * stds[j])).abs();
if corr > 0.1 {
// Bidirectional edges for flow network
let weight = corr * 10.0; // Scale up for integer-like flow
edges.push((i as u64, j as u64, weight));
edges.push((j as u64, i as u64, weight));
}
}
}
// Source → highest-variance subcarrier, Sink → lowest-variance
let (max_var_idx, _) = active.iter().enumerate()
.max_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.unwrap_or((0, &0));
let (min_var_idx, _) = active.iter().enumerate()
.min_by(|(_, &a), (_, &b)| variances[a].partial_cmp(&variances[b]).unwrap())
.unwrap_or((0, &0));
if max_var_idx == min_var_idx {
return 1;
}
edges.push((source, max_var_idx as u64, 100.0));
edges.push((min_var_idx as u64, sink, 100.0));
// Run min-cut
let mc: DynamicMinCut = match MinCutBuilder::new().exact().with_edges(edges.clone()).build() {
Ok(mc) => mc,
Err(_) => return 1,
};
let cut_value = mc.min_cut_value();
let total_edge_weight: f64 = edges.iter()
.filter(|(s, t, _)| *s != source && *s != sink && *t != source && *t != sink)
.map(|(_, _, w)| w)
.sum::<f64>() / 2.0; // bidirectional → halve
if total_edge_weight < 1e-9 {
return 1;
}
// Normalized cut ratio: low = easy to split = multiple people
let cut_ratio = cut_value / total_edge_weight;
if cut_ratio > 0.4 {
1 // Tightly coupled — one person
} else if cut_ratio > 0.15 {
2 // Moderately separable — two people
} else {
3 // Highly separable — three+ people
}
}
/// Convert smoothed person score to discrete count with hysteresis.
@ -2092,9 +2203,9 @@ fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize {
// 3→2: 0.78 (hysteresis gap of 0.14)
match prev_count {
0 | 1 => {
if smoothed_score > 0.92 {
if smoothed_score > 0.85 {
3
} else if smoothed_score > 0.80 {
} else if smoothed_score > 0.70 {
2
} else {
1
@ -3473,10 +3584,13 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
let vitals = smooth_vitals_node(ns, &raw_vitals);
ns.latest_vitals = vitals.clone();
let raw_score = compute_person_score(&features);
// Slower EMA (0.05) for person score to prevent count flips
// from frame-to-frame variance oscillation in fused features.
ns.smoothed_person_score = ns.smoothed_person_score * 0.95 + raw_score * 0.05;
// Use correlation-based person estimation from frame history.
// This examines the temporal correlation structure of CSI
// subcarriers — correlated subcarriers belong to the same
// person, independent clusters indicate multiple people.
let corr_persons = estimate_persons_from_correlation(&ns.frame_history);
let raw_score = corr_persons as f64 / 3.0; // normalize to 0..1
ns.smoothed_person_score = ns.smoothed_person_score * 0.92 + raw_score * 0.08;
if classification.presence {
let count = score_to_person_count(ns.smoothed_person_score, ns.prev_person_count);
ns.prev_person_count = count;