mirror of
https://github.com/ruvnet/RuView.git
synced 2026-05-22 03:17:34 +00:00
fix: skeleton jitter + person count stability (hardware-verified)
* chore: update vendored ruvector to latest main (v2.1.0-40) Was at v2.0.5-172 (f8f2c600a), now at v2.1.0-40 (050c3fe6f). 316 commits with new crates: ruvector-coherence, sona, ruvector-core, ruvector-gnn improvements, and security hardening. Co-Authored-By: claude-flow <ruv@ruv.net> * feat: RuVector Phases 2+3 — temporal smoothing, kinematic constraints, coherence gating Phase 2 (sensing server): - Temporal keypoint smoothing via EMA (alpha=0.3) with coherence-adaptive blending - Coherence scoring: running variance of motion_energy over 20 frames - Low coherence → reduce alpha to 0.1 (trust measurements less) - Per-node prev_keypoints for frame-to-frame smoothing - Bone length clamping (±20%) in derive_single_person_pose Phase 3 (signal crate): - SkeletonConstraints: Jakobsen relaxation (3 iterations) on 12-bone COCO-17 kinematic tree — prevents impossible skeletons - CompressedPoseHistory: two-tier storage (hot f32 + warm i16 quantized) for trajectory matching and re-ID - 8 new tests for constraints + history Vendored ruvector updated to v2.1.0-40 (latest main, 316 commits). Workspace deps remain at v2.0.4 (crates.io) until v2.1.0 is published. 647 tests pass across both crates (0 failures). Refs #296 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(server): use max instead of sum for multi-node person aggregation With nodes in the same room, each node sees the same people. Summing per-node counts double-counted (2 nodes × 1 person = 2 persons). Now uses max() so 2 nodes × 1 person = 1 person. Verified on real hardware: COM6 (node 1) + COM9 (node 2) on ruv.net, estimated_persons=1 with 1 person in room. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(server): reduce skeleton jitter + raise person count thresholds - EMA alpha 0.3→0.15, low-coherence 0.1→0.05 - Remove tick-based noise (main jitter source) - Breathing 5x slower, extremity jitter 3x smaller, stride 2x smaller - Person count 1→2 threshold 0.65→0.80 - Aggregation sum→max for same-room nodes Verified on COM6+COM9: 1 person stable. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
5e5781b28a
commit
dd45160cc5
1 changed files with 36 additions and 22 deletions
|
|
@ -309,9 +309,11 @@ struct NodeState {
|
|||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
const TEMPORAL_EMA_ALPHA_DEFAULT: f64 = 0.3;
|
||||
/// Lower = smoother (more history, less jitter). 0.15 balances responsiveness
|
||||
/// with stability for WiFi CSI where per-frame noise is high.
|
||||
const TEMPORAL_EMA_ALPHA_DEFAULT: f64 = 0.15;
|
||||
/// Reduced EMA alpha when coherence is low (trust measurements less).
|
||||
const TEMPORAL_EMA_ALPHA_LOW_COHERENCE: f64 = 0.1;
|
||||
const TEMPORAL_EMA_ALPHA_LOW_COHERENCE: f64 = 0.05;
|
||||
/// Coherence threshold below which we reduce EMA alpha.
|
||||
const COHERENCE_LOW_THRESHOLD: f64 = 0.3;
|
||||
/// Maximum allowed bone-length change ratio between frames (20%).
|
||||
|
|
@ -2024,25 +2026,26 @@ fn compute_person_score(feat: &FeatureInfo) -> f64 {
|
|||
/// (the #1 user-reported issue — see #237, #249, #280, #292).
|
||||
fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize {
|
||||
// Up-thresholds (must exceed to increase count):
|
||||
// 1→2: 0.65 (raised from 0.50 — multipath in small rooms hit 0.50 easily)
|
||||
// 2→3: 0.85 (raised from 0.80 — 3 persons needs strong sustained signal)
|
||||
// 1→2: 0.80 (raised from 0.65 — single-person movement in multipath
|
||||
// rooms easily hits 0.65, causing false 2-person detection)
|
||||
// 2→3: 0.92 (raised from 0.85 — 3 persons needs very strong signal)
|
||||
// Down-thresholds (must drop below to decrease count):
|
||||
// 2→1: 0.45 (hysteresis gap of 0.20)
|
||||
// 3→2: 0.70 (hysteresis gap of 0.15)
|
||||
// 2→1: 0.55 (hysteresis gap of 0.25)
|
||||
// 3→2: 0.78 (hysteresis gap of 0.14)
|
||||
match prev_count {
|
||||
0 | 1 => {
|
||||
if smoothed_score > 0.85 {
|
||||
if smoothed_score > 0.92 {
|
||||
3
|
||||
} else if smoothed_score > 0.65 {
|
||||
} else if smoothed_score > 0.80 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if smoothed_score > 0.85 {
|
||||
if smoothed_score > 0.92 {
|
||||
3
|
||||
} else if smoothed_score < 0.45 {
|
||||
} else if smoothed_score < 0.55 {
|
||||
1
|
||||
} else {
|
||||
2 // hold — within hysteresis band
|
||||
|
|
@ -2050,9 +2053,9 @@ fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize {
|
|||
}
|
||||
_ => {
|
||||
// prev_count >= 3
|
||||
if smoothed_score < 0.45 {
|
||||
if smoothed_score < 0.55 {
|
||||
1
|
||||
} else if smoothed_score < 0.70 {
|
||||
} else if smoothed_score < 0.78 {
|
||||
2
|
||||
} else {
|
||||
3 // hold
|
||||
|
|
@ -2092,23 +2095,27 @@ fn derive_single_person_pose(
|
|||
let breath_phase = if let Some(ref vs) = update.vital_signs {
|
||||
let bpm = vs.breathing_rate_bpm.unwrap_or(15.0);
|
||||
let freq = (bpm / 60.0).clamp(0.1, 0.5);
|
||||
(update.tick as f64 * freq * 0.1 * std::f64::consts::TAU + phase_offset).sin()
|
||||
// Slow tick rate (0.02) for gentle breathing, not jerky oscillation.
|
||||
(update.tick as f64 * freq * 0.02 * std::f64::consts::TAU + phase_offset).sin()
|
||||
} else {
|
||||
(update.tick as f64 * 0.08 + feat.breathing_band_power + phase_offset).sin()
|
||||
(update.tick as f64 * 0.02 + phase_offset).sin()
|
||||
};
|
||||
|
||||
let lean_x = (feat.dominant_freq_hz / 5.0 - 1.0).clamp(-1.0, 1.0) * 18.0;
|
||||
|
||||
let stride_x = if is_walking {
|
||||
let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin();
|
||||
stride_phase * 45.0 * motion_score
|
||||
let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.06 + phase_offset).sin();
|
||||
stride_phase * 20.0 * motion_score
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let burst = (feat.change_points as f64 / 8.0).clamp(0.0, 1.0);
|
||||
// Dampen burst and noise to reduce jitter. The original used
|
||||
// tick*17.3 which changed wildly every frame. Now use slow tick
|
||||
// rate and minimal burst scaling for a stable skeleton.
|
||||
let burst = (feat.change_points as f64 / 20.0).clamp(0.0, 0.3);
|
||||
|
||||
let noise_seed = feat.variance * 31.7 + update.tick as f64 * 17.3 + person_idx as f64 * 97.1;
|
||||
let noise_seed = person_idx as f64 * 97.1; // stable per-person, no tick
|
||||
let noise_val = (noise_seed.sin() * 43758.545).fract();
|
||||
|
||||
let snr_factor = ((feat.variance - 0.5) / 10.0).clamp(0.0, 1.0);
|
||||
|
|
@ -2169,9 +2176,10 @@ fn derive_single_person_pose(
|
|||
|
||||
let extremity_jitter = if EXTREMITY_KP.contains(&i) {
|
||||
let phase = noise_seed + i as f64 * 2.399;
|
||||
// Dampened from 12/8 to 4/3 to reduce visual jumping.
|
||||
(
|
||||
phase.sin() * burst * motion_score * 12.0,
|
||||
(phase * 1.31).cos() * burst * motion_score * 8.0,
|
||||
phase.sin() * burst * motion_score * 4.0,
|
||||
(phase * 1.31).cos() * burst * motion_score * 3.0,
|
||||
)
|
||||
} else {
|
||||
(0.0, 0.0)
|
||||
|
|
@ -3210,11 +3218,14 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
else { 0.05 };
|
||||
|
||||
// Aggregate person count across all active nodes.
|
||||
// Use max (not sum) because nodes in the same room see the
|
||||
// same people — summing would double-count.
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons: usize = s.node_states.values()
|
||||
.filter(|n| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
|
||||
.map(|n| n.prev_person_count)
|
||||
.sum();
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
let active_nodes: Vec<NodeInfo> = s.node_states.iter()
|
||||
|
|
@ -3413,11 +3424,14 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
else { 0.05 };
|
||||
|
||||
// Aggregate person count across all active nodes.
|
||||
// Use max (not sum) because nodes in the same room see the
|
||||
// same people — summing would double-count.
|
||||
let now = std::time::Instant::now();
|
||||
let total_persons: usize = s.node_states.values()
|
||||
.filter(|n| n.last_frame_time.map_or(false, |t| now.duration_since(t).as_secs() < 10))
|
||||
.map(|n| n.prev_person_count)
|
||||
.sum();
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
let active_nodes: Vec<NodeInfo> = s.node_states.iter()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue