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:
rUv 2026-03-30 15:17:48 -04:00 committed by GitHub
parent 5e5781b28a
commit dd45160cc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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()