mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
f49c722764
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/) without any sibling under rust-port/ that warranted the extra level. Move the whole workspace up to v2/ to match v1/ (Python) at the same depth and shorten every cd / build command across the repo. git mv preserves history for all tracked files. 60 files updated for path references (CI workflows, ADRs, docs, scripts, READMEs, internal .claude-flow state). Two manual fixes for relative-cd paths in CLAUDE.md and ADR-043 that became wrong after the depth change (cd ../.. → cd ..). Validated: - cargo check --workspace --no-default-features → clean (after target/ nuke; the gitignored target/ was carried by the OS rename and had hard-coded old paths in build scripts) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (same totals as pre-rename) - ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm) After-merge follow-up: contributors should `rm -rf v2/target` once and let cargo regenerate from the new path.
260 lines
8.9 KiB
Rust
260 lines
8.9 KiB
Rust
//! Sliced Wasserstein distance for geometric motion detection (ADR-041).
|
|
//!
|
|
//! Computes 1D Wasserstein distance between current/previous CSI amplitude
|
|
//! distributions via 4 fixed random projections. Detects "subtle motion"
|
|
//! when Wasserstein is elevated but total variance is stable.
|
|
//! Events: WASSERSTEIN_DISTANCE(725), DISTRIBUTION_SHIFT(726), SUBTLE_MOTION(727).
|
|
|
|
use libm::fabsf;
|
|
|
|
const MAX_SC: usize = 32;
|
|
const N_PROJ: usize = 4;
|
|
const ALPHA: f32 = 0.15;
|
|
const VAR_ALPHA: f32 = 0.1;
|
|
const WASS_SHIFT: f32 = 0.25;
|
|
const WASS_SUBTLE: f32 = 0.10;
|
|
const VAR_STABLE: f32 = 0.15;
|
|
const SHIFT_DEB: u8 = 3;
|
|
const SUBTLE_DEB: u8 = 5;
|
|
|
|
pub const EVENT_WASSERSTEIN_DISTANCE: i32 = 725;
|
|
pub const EVENT_DISTRIBUTION_SHIFT: i32 = 726;
|
|
pub const EVENT_SUBTLE_MOTION: i32 = 727;
|
|
|
|
/// Deterministic projection directions via LCG PRNG, L2-normalized.
|
|
const PROJ: [[f32; MAX_SC]; N_PROJ] = gen_proj();
|
|
|
|
const fn gen_proj() -> [[f32; MAX_SC]; N_PROJ] {
|
|
let seeds = [42u32, 137, 2718, 31415];
|
|
let mut dirs = [[0.0f32; MAX_SC]; N_PROJ];
|
|
let mut p = 0;
|
|
while p < N_PROJ {
|
|
let mut st = seeds[p];
|
|
let mut raw = [0.0f32; MAX_SC];
|
|
let mut i = 0;
|
|
while i < MAX_SC {
|
|
st = st.wrapping_mul(1103515245).wrapping_add(12345) & 0x7FFF_FFFF;
|
|
raw[i] = (st as f32 / 1_073_741_823.0) * 2.0 - 1.0;
|
|
i += 1;
|
|
}
|
|
let mut sq = 0.0f32;
|
|
i = 0; while i < MAX_SC { sq += raw[i] * raw[i]; i += 1; }
|
|
// Newton-Raphson sqrt (6 iters).
|
|
let mut norm = sq * 0.5;
|
|
if norm < 1e-9 { norm = 1.0; }
|
|
let mut k = 0; while k < 6 { norm = 0.5 * (norm + sq / norm); k += 1; }
|
|
i = 0; while i < MAX_SC { dirs[p][i] = raw[i] / norm; i += 1; }
|
|
p += 1;
|
|
}
|
|
dirs
|
|
}
|
|
|
|
/// Shell sort with Ciura gap sequence -- O(n^1.3) vs insertion sort's O(n^2).
|
|
/// For n=32 this reduces worst-case from ~1024 to ~128 comparisons per sort.
|
|
/// 8 sorts per frame (2 per projection * 4 projections) = significant savings.
|
|
fn shell_sort(a: &mut [f32], n: usize) {
|
|
// Ciura gap sequence (truncated for n<=32).
|
|
const GAPS: [usize; 4] = [10, 4, 1, 0];
|
|
let mut gi = 0;
|
|
while gi < 3 {
|
|
let gap = GAPS[gi];
|
|
if gap >= n { gi += 1; continue; }
|
|
let mut i = gap;
|
|
while i < n {
|
|
let k = a[i];
|
|
let mut j = i;
|
|
while j >= gap && a[j - gap] > k {
|
|
a[j] = a[j - gap];
|
|
j -= gap;
|
|
}
|
|
a[j] = k;
|
|
i += 1;
|
|
}
|
|
gi += 1;
|
|
}
|
|
}
|
|
|
|
/// Sliced Wasserstein motion detector.
|
|
pub struct OptimalTransportDetector {
|
|
prev_amps: [f32; MAX_SC],
|
|
smoothed_dist: f32,
|
|
smoothed_var: f32,
|
|
prev_var: f32,
|
|
initialized: bool,
|
|
frame_count: u32,
|
|
shift_streak: u8,
|
|
subtle_streak: u8,
|
|
}
|
|
|
|
impl OptimalTransportDetector {
|
|
pub const fn new() -> Self {
|
|
Self { prev_amps: [0.0; MAX_SC], smoothed_dist: 0.0, smoothed_var: 0.0, prev_var: 0.0,
|
|
initialized: false, frame_count: 0, shift_streak: 0, subtle_streak: 0 }
|
|
}
|
|
|
|
fn w1_sorted(a: &[f32], b: &[f32], n: usize) -> f32 {
|
|
if n == 0 { return 0.0; }
|
|
let mut s = 0.0f32;
|
|
let mut i = 0; while i < n { s += fabsf(a[i] - b[i]); i += 1; }
|
|
s / n as f32
|
|
}
|
|
|
|
fn sliced_w(cur: &[f32], prev: &[f32], n: usize) -> f32 {
|
|
let mut total = 0.0f32;
|
|
let mut p = 0;
|
|
while p < N_PROJ {
|
|
let mut pc = [0.0f32; MAX_SC];
|
|
let mut pp = [0.0f32; MAX_SC];
|
|
let mut i = 0;
|
|
while i < n { pc[i] = cur[i] * PROJ[p][i]; pp[i] = prev[i] * PROJ[p][i]; i += 1; }
|
|
shell_sort(&mut pc, n);
|
|
shell_sort(&mut pp, n);
|
|
total += Self::w1_sorted(&pc, &pp, n);
|
|
p += 1;
|
|
}
|
|
total / N_PROJ as f32
|
|
}
|
|
|
|
fn variance(a: &[f32], n: usize) -> f32 {
|
|
if n == 0 { return 0.0; }
|
|
let mut m = 0.0f32;
|
|
let mut i = 0; while i < n { m += a[i]; i += 1; } m /= n as f32;
|
|
let mut v = 0.0f32;
|
|
i = 0; while i < n { let d = a[i] - m; v += d * d; i += 1; }
|
|
v / n as f32
|
|
}
|
|
|
|
/// Process one frame of amplitude data. Returns events.
|
|
pub fn process_frame(&mut self, amplitudes: &[f32]) -> &[(i32, f32)] {
|
|
let n = amplitudes.len().min(MAX_SC);
|
|
if n < 2 { return &[]; }
|
|
self.frame_count += 1;
|
|
let mut cur = [0.0f32; MAX_SC];
|
|
let mut i = 0; while i < n { cur[i] = amplitudes[i]; i += 1; }
|
|
|
|
if !self.initialized {
|
|
i = 0; while i < n { self.prev_amps[i] = cur[i]; i += 1; }
|
|
self.smoothed_var = Self::variance(&cur, n);
|
|
self.prev_var = self.smoothed_var;
|
|
self.initialized = true;
|
|
return &[];
|
|
}
|
|
|
|
let raw_w = Self::sliced_w(&cur, &self.prev_amps, n);
|
|
self.smoothed_dist = ALPHA * raw_w + (1.0 - ALPHA) * self.smoothed_dist;
|
|
|
|
let cv = Self::variance(&cur, n);
|
|
self.prev_var = self.smoothed_var;
|
|
self.smoothed_var = VAR_ALPHA * cv + (1.0 - VAR_ALPHA) * self.smoothed_var;
|
|
let vc = if self.prev_var > 1e-6 { fabsf(self.smoothed_var - self.prev_var) / self.prev_var } else { 0.0 };
|
|
|
|
i = 0; while i < n { self.prev_amps[i] = cur[i]; i += 1; }
|
|
|
|
static mut EV: [(i32, f32); 4] = [(0, 0.0); 4];
|
|
let mut ne = 0usize;
|
|
|
|
if self.frame_count % 5 == 0 && ne < 4 {
|
|
unsafe { EV[ne] = (EVENT_WASSERSTEIN_DISTANCE, self.smoothed_dist); } ne += 1;
|
|
}
|
|
if self.smoothed_dist > WASS_SHIFT {
|
|
self.shift_streak = self.shift_streak.saturating_add(1);
|
|
if self.shift_streak >= SHIFT_DEB && ne < 4 {
|
|
unsafe { EV[ne] = (EVENT_DISTRIBUTION_SHIFT, self.smoothed_dist); } ne += 1;
|
|
self.shift_streak = 0;
|
|
}
|
|
} else { self.shift_streak = 0; }
|
|
|
|
if self.smoothed_dist > WASS_SUBTLE && vc < VAR_STABLE {
|
|
self.subtle_streak = self.subtle_streak.saturating_add(1);
|
|
if self.subtle_streak >= SUBTLE_DEB && ne < 4 {
|
|
unsafe { EV[ne] = (EVENT_SUBTLE_MOTION, self.smoothed_dist); } ne += 1;
|
|
self.subtle_streak = 0;
|
|
}
|
|
} else { self.subtle_streak = 0; }
|
|
|
|
unsafe { &EV[..ne] }
|
|
}
|
|
|
|
pub fn distance(&self) -> f32 { self.smoothed_dist }
|
|
pub fn variance_smoothed(&self) -> f32 { self.smoothed_var }
|
|
pub fn frame_count(&self) -> u32 { self.frame_count }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_init() { let d = OptimalTransportDetector::new(); assert_eq!(d.frame_count(), 0); }
|
|
|
|
#[test]
|
|
fn test_identical_zero() {
|
|
let mut d = OptimalTransportDetector::new();
|
|
let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
|
|
d.process_frame(&a); d.process_frame(&a);
|
|
assert!(d.distance() < 0.01, "identical => ~0, got {}", d.distance());
|
|
}
|
|
|
|
#[test]
|
|
fn test_different_nonzero() {
|
|
let mut d = OptimalTransportDetector::new();
|
|
d.process_frame(&[1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
|
|
d.process_frame(&[8.0f32, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]);
|
|
assert!(d.distance() > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shift_event() {
|
|
let mut d = OptimalTransportDetector::new();
|
|
d.process_frame(&[1.0f32; 16]);
|
|
let mut found = false;
|
|
// Alternate between two very different distributions so every frame
|
|
// produces a large Wasserstein distance, allowing the EMA to exceed
|
|
// WASS_SHIFT and the debounce counter to reach SHIFT_DEB.
|
|
for i in 0..40 {
|
|
let amps = if i % 2 == 0 { [20.0f32; 16] } else { [1.0f32; 16] };
|
|
for &(t, _) in d.process_frame(&s) {
|
|
if t == EVENT_DISTRIBUTION_SHIFT { found = true; }
|
|
}
|
|
}
|
|
assert!(found, "large shift should trigger event");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sort() {
|
|
let mut a = [5.0f32, 3.0, 8.0, 1.0, 4.0]; shell_sort(&mut a, 5);
|
|
assert_eq!([a[0], a[1], a[2], a[3], a[4]], [1.0, 3.0, 4.0, 5.0, 8.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_w1() {
|
|
let a = [1.0f32, 2.0, 3.0, 4.0]; let b = [2.0f32, 3.0, 4.0, 5.0];
|
|
assert!(fabsf(OptimalTransportDetector::w1_sorted(&a, &b, 4) - 1.0) < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_proj_normalized() {
|
|
for p in 0..N_PROJ {
|
|
let mut sq = 0.0f32; for i in 0..MAX_SC { sq += PROJ[p][i] * PROJ[p][i]; }
|
|
assert!(fabsf(libm::sqrtf(sq) - 1.0) < 0.05, "proj {p} norm err");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_variance_calc() {
|
|
let v = OptimalTransportDetector::variance(&[2.0f32, 4.0, 6.0, 8.0], 4);
|
|
assert!(fabsf(v - 5.0) < 0.01, "var={v}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_stable_no_events() {
|
|
let mut d = OptimalTransportDetector::new();
|
|
d.process_frame(&[3.0f32; 16]);
|
|
for _ in 0..50 {
|
|
for &(t, _) in d.process_frame(&[3.0f32; 16]) {
|
|
assert!(t != EVENT_DISTRIBUTION_SHIFT && t != EVENT_SUBTLE_MOTION);
|
|
}
|
|
}
|
|
}
|
|
}
|