Files
wifi-densepose/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/gesture.rs
ruv 37b54d649b feat: implement ADR-029/030/031 — RuvSense multistatic sensing + field model + RuView fusion
12,126 lines of new Rust code across 22 modules with 285 tests:

ADR-029 RuvSense Core (signal crate, 10 modules):
- multiband.rs: Multi-band CSI frame fusion from channel hopping
- phase_align.rs: Cross-channel LO phase rotation correction
- multistatic.rs: Attention-weighted cross-node viewpoint fusion
- coherence.rs: Z-score per-subcarrier coherence scoring
- coherence_gate.rs: Accept/PredictOnly/Reject/Recalibrate gating
- pose_tracker.rs: 17-keypoint Kalman tracker with re-ID
- mod.rs: Pipeline orchestrator

ADR-030 Persistent Field Model (signal crate, 7 modules):
- field_model.rs: SVD-based room eigenstructure, Welford stats
- tomography.rs: Coarse RF tomography from link attenuations (ISTA)
- longitudinal.rs: Personal baseline drift detection over days
- intention.rs: Pre-movement prediction (200-500ms lead signals)
- cross_room.rs: Cross-room identity continuity
- gesture.rs: Gesture classification via DTW template matching
- adversarial.rs: Physically impossible signal detection

ADR-031 RuView (ruvector crate, 5 modules):
- attention.rs: Scaled dot-product with geometric bias
- geometry.rs: Geometric Diversity Index, Cramer-Rao bounds
- coherence.rs: Phase phasor coherence gating
- fusion.rs: MultistaticArray aggregate, fusion orchestrator
- mod.rs: Module exports

Training & Hardware:
- ruview_metrics.rs: 3-metric acceptance test (PCK/OKS, MOTA, vitals)
- esp32/tdm.rs: TDM sensing protocol, sync beacons, drift compensation
- Firmware: channel hopping, NDP injection, NVS config extensions

Security fixes:
- field_model.rs: saturating_sub prevents timestamp underflow
- longitudinal.rs: FIFO eviction note for bounded buffer

README updated with RuvSense section, new feature badges, changelog v3.1.0.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-01 21:39:02 -05:00

580 lines
18 KiB
Rust

//! Gesture classification from per-person CSI perturbation patterns.
//!
//! Classifies gestures by comparing per-person CSI perturbation time
//! series against a library of gesture templates using Dynamic Time
//! Warping (DTW). Works through walls and darkness because it operates
//! on RF perturbations, not visual features.
//!
//! # Algorithm
//! 1. Collect per-person CSI perturbation over a gesture window (~1s)
//! 2. Normalize and project onto principal components
//! 3. Compare against stored gesture templates using DTW distance
//! 4. Classify as the nearest template if distance < threshold
//!
//! # Supported Gestures
//! Wave, point, beckon, push, circle, plus custom user-defined templates.
//!
//! # References
//! - ADR-030 Tier 6: Invisible Interaction Layer
//! - Sakoe & Chiba (1978), "Dynamic programming algorithm optimization
//! for spoken word recognition" IEEE TASSP
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/// Errors from gesture classification.
#[derive(Debug, thiserror::Error)]
pub enum GestureError {
/// Gesture sequence too short.
#[error("Sequence too short: need >= {needed} frames, got {got}")]
SequenceTooShort { needed: usize, got: usize },
/// No templates registered for classification.
#[error("No gesture templates registered")]
NoTemplates,
/// Feature dimension mismatch.
#[error("Feature dimension mismatch: expected {expected}, got {got}")]
DimensionMismatch { expected: usize, got: usize },
/// Invalid template name.
#[error("Invalid template name: {0}")]
InvalidTemplateName(String),
}
// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------
/// Built-in gesture categories.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GestureType {
/// Waving hand (side to side).
Wave,
/// Pointing at a target.
Point,
/// Beckoning (come here).
Beckon,
/// Push forward motion.
Push,
/// Circular motion.
Circle,
/// User-defined custom gesture.
Custom,
}
impl GestureType {
/// Human-readable name.
pub fn name(&self) -> &'static str {
match self {
GestureType::Wave => "wave",
GestureType::Point => "point",
GestureType::Beckon => "beckon",
GestureType::Push => "push",
GestureType::Circle => "circle",
GestureType::Custom => "custom",
}
}
}
/// A gesture template: a reference time series for a known gesture.
#[derive(Debug, Clone)]
pub struct GestureTemplate {
/// Unique template name (e.g., "wave_right", "push_forward").
pub name: String,
/// Gesture category.
pub gesture_type: GestureType,
/// Template feature sequence: `[n_frames][feature_dim]`.
pub sequence: Vec<Vec<f64>>,
/// Feature dimension.
pub feature_dim: usize,
}
/// Result of gesture classification.
#[derive(Debug, Clone)]
pub struct GestureResult {
/// Whether a gesture was recognized.
pub recognized: bool,
/// Matched gesture type (if recognized).
pub gesture_type: Option<GestureType>,
/// Matched template name (if recognized).
pub template_name: Option<String>,
/// DTW distance to best match.
pub distance: f64,
/// Confidence (0.0 to 1.0, based on relative distances).
pub confidence: f64,
/// Person ID this gesture belongs to.
pub person_id: u64,
/// Timestamp (microseconds).
pub timestamp_us: u64,
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Configuration for the gesture classifier.
#[derive(Debug, Clone)]
pub struct GestureConfig {
/// Feature dimension of perturbation vectors.
pub feature_dim: usize,
/// Minimum sequence length (frames) for a valid gesture.
pub min_sequence_len: usize,
/// Maximum DTW distance for a match (lower = stricter).
pub max_distance: f64,
/// DTW Sakoe-Chiba band width (constrains warping).
pub band_width: usize,
}
impl Default for GestureConfig {
fn default() -> Self {
Self {
feature_dim: 8,
min_sequence_len: 10,
max_distance: 50.0,
band_width: 5,
}
}
}
// ---------------------------------------------------------------------------
// Gesture classifier
// ---------------------------------------------------------------------------
/// Gesture classifier using DTW template matching.
///
/// Maintains a library of gesture templates and classifies new
/// perturbation sequences by finding the nearest template.
#[derive(Debug)]
pub struct GestureClassifier {
config: GestureConfig,
templates: Vec<GestureTemplate>,
}
impl GestureClassifier {
/// Create a new gesture classifier.
pub fn new(config: GestureConfig) -> Self {
Self {
config,
templates: Vec::new(),
}
}
/// Register a gesture template.
pub fn add_template(&mut self, template: GestureTemplate) -> Result<(), GestureError> {
if template.name.is_empty() {
return Err(GestureError::InvalidTemplateName(
"Template name cannot be empty".into(),
));
}
if template.feature_dim != self.config.feature_dim {
return Err(GestureError::DimensionMismatch {
expected: self.config.feature_dim,
got: template.feature_dim,
});
}
if template.sequence.len() < self.config.min_sequence_len {
return Err(GestureError::SequenceTooShort {
needed: self.config.min_sequence_len,
got: template.sequence.len(),
});
}
self.templates.push(template);
Ok(())
}
/// Number of registered templates.
pub fn template_count(&self) -> usize {
self.templates.len()
}
/// Classify a perturbation sequence against registered templates.
///
/// `sequence` is `[n_frames][feature_dim]` of perturbation features.
pub fn classify(
&self,
sequence: &[Vec<f64>],
person_id: u64,
timestamp_us: u64,
) -> Result<GestureResult, GestureError> {
if self.templates.is_empty() {
return Err(GestureError::NoTemplates);
}
if sequence.len() < self.config.min_sequence_len {
return Err(GestureError::SequenceTooShort {
needed: self.config.min_sequence_len,
got: sequence.len(),
});
}
// Validate feature dimension
for frame in sequence {
if frame.len() != self.config.feature_dim {
return Err(GestureError::DimensionMismatch {
expected: self.config.feature_dim,
got: frame.len(),
});
}
}
// Compute DTW distance to each template
let mut best_dist = f64::INFINITY;
let mut second_best_dist = f64::INFINITY;
let mut best_idx: Option<usize> = None;
for (idx, template) in self.templates.iter().enumerate() {
let dist = dtw_distance(sequence, &template.sequence, self.config.band_width);
if dist < best_dist {
second_best_dist = best_dist;
best_dist = dist;
best_idx = Some(idx);
} else if dist < second_best_dist {
second_best_dist = dist;
}
}
let recognized = best_dist <= self.config.max_distance;
// Confidence: how much better is the best match vs second best
let confidence = if recognized && second_best_dist.is_finite() && second_best_dist > 1e-10 {
(1.0 - best_dist / second_best_dist).clamp(0.0, 1.0)
} else if recognized {
(1.0 - best_dist / self.config.max_distance).clamp(0.0, 1.0)
} else {
0.0
};
if let Some(idx) = best_idx {
let template = &self.templates[idx];
Ok(GestureResult {
recognized,
gesture_type: if recognized {
Some(template.gesture_type)
} else {
None
},
template_name: if recognized {
Some(template.name.clone())
} else {
None
},
distance: best_dist,
confidence,
person_id,
timestamp_us,
})
} else {
Ok(GestureResult {
recognized: false,
gesture_type: None,
template_name: None,
distance: f64::INFINITY,
confidence: 0.0,
person_id,
timestamp_us,
})
}
}
}
// ---------------------------------------------------------------------------
// Dynamic Time Warping
// ---------------------------------------------------------------------------
/// Compute DTW distance between two multivariate time series.
///
/// Uses the Sakoe-Chiba band constraint to limit warping.
/// Each frame is a vector of `feature_dim` dimensions.
fn dtw_distance(seq_a: &[Vec<f64>], seq_b: &[Vec<f64>], band_width: usize) -> f64 {
let n = seq_a.len();
let m = seq_b.len();
if n == 0 || m == 0 {
return f64::INFINITY;
}
// Cost matrix (only need 2 rows for memory efficiency)
let mut prev = vec![f64::INFINITY; m + 1];
let mut curr = vec![f64::INFINITY; m + 1];
prev[0] = 0.0;
for i in 1..=n {
curr[0] = f64::INFINITY;
let j_start = if band_width >= i {
1
} else {
i.saturating_sub(band_width).max(1)
};
let j_end = (i + band_width).min(m);
for j in 1..=m {
if j < j_start || j > j_end {
curr[j] = f64::INFINITY;
continue;
}
let cost = euclidean_distance(&seq_a[i - 1], &seq_b[j - 1]);
curr[j] = cost
+ prev[j] // insertion
.min(curr[j - 1]) // deletion
.min(prev[j - 1]); // match
}
std::mem::swap(&mut prev, &mut curr);
}
prev[m]
}
/// Euclidean distance between two feature vectors.
fn euclidean_distance(a: &[f64], b: &[f64]) -> f64 {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y) * (x - y))
.sum::<f64>()
.sqrt()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_template(
name: &str,
gesture_type: GestureType,
n_frames: usize,
feature_dim: usize,
pattern: fn(usize, usize) -> f64,
) -> GestureTemplate {
let sequence: Vec<Vec<f64>> = (0..n_frames)
.map(|t| (0..feature_dim).map(|d| pattern(t, d)).collect())
.collect();
GestureTemplate {
name: name.to_string(),
gesture_type,
sequence,
feature_dim,
}
}
fn wave_pattern(t: usize, d: usize) -> f64 {
if d == 0 {
(t as f64 * 0.5).sin()
} else {
0.0
}
}
fn push_pattern(t: usize, d: usize) -> f64 {
if d == 0 {
t as f64 * 0.1
} else {
0.0
}
}
fn small_config() -> GestureConfig {
GestureConfig {
feature_dim: 4,
min_sequence_len: 5,
max_distance: 10.0,
band_width: 3,
}
}
#[test]
fn test_classifier_creation() {
let classifier = GestureClassifier::new(small_config());
assert_eq!(classifier.template_count(), 0);
}
#[test]
fn test_add_template() {
let mut classifier = GestureClassifier::new(small_config());
let template = make_template("wave", GestureType::Wave, 10, 4, wave_pattern);
classifier.add_template(template).unwrap();
assert_eq!(classifier.template_count(), 1);
}
#[test]
fn test_add_template_empty_name() {
let mut classifier = GestureClassifier::new(small_config());
let template = make_template("", GestureType::Wave, 10, 4, wave_pattern);
assert!(matches!(
classifier.add_template(template),
Err(GestureError::InvalidTemplateName(_))
));
}
#[test]
fn test_add_template_wrong_dim() {
let mut classifier = GestureClassifier::new(small_config());
let template = make_template("wave", GestureType::Wave, 10, 8, wave_pattern);
assert!(matches!(
classifier.add_template(template),
Err(GestureError::DimensionMismatch { .. })
));
}
#[test]
fn test_add_template_too_short() {
let mut classifier = GestureClassifier::new(small_config());
let template = make_template("wave", GestureType::Wave, 3, 4, wave_pattern);
assert!(matches!(
classifier.add_template(template),
Err(GestureError::SequenceTooShort { .. })
));
}
#[test]
fn test_classify_no_templates() {
let classifier = GestureClassifier::new(small_config());
let seq: Vec<Vec<f64>> = (0..10).map(|_| vec![0.0; 4]).collect();
assert!(matches!(
classifier.classify(&seq, 1, 0),
Err(GestureError::NoTemplates)
));
}
#[test]
fn test_classify_exact_match() {
let mut classifier = GestureClassifier::new(small_config());
let template = make_template("wave", GestureType::Wave, 10, 4, wave_pattern);
classifier.add_template(template).unwrap();
// Feed the exact same pattern
let seq: Vec<Vec<f64>> = (0..10)
.map(|t| (0..4).map(|d| wave_pattern(t, d)).collect())
.collect();
let result = classifier.classify(&seq, 1, 100_000).unwrap();
assert!(result.recognized);
assert_eq!(result.gesture_type, Some(GestureType::Wave));
assert!(
result.distance < 1e-10,
"Exact match should have zero distance"
);
}
#[test]
fn test_classify_best_of_two() {
let mut classifier = GestureClassifier::new(GestureConfig {
max_distance: 100.0,
..small_config()
});
classifier
.add_template(make_template(
"wave",
GestureType::Wave,
10,
4,
wave_pattern,
))
.unwrap();
classifier
.add_template(make_template(
"push",
GestureType::Push,
10,
4,
push_pattern,
))
.unwrap();
// Feed a wave-like pattern
let seq: Vec<Vec<f64>> = (0..10)
.map(|t| (0..4).map(|d| wave_pattern(t, d) + 0.01).collect())
.collect();
let result = classifier.classify(&seq, 1, 0).unwrap();
assert!(result.recognized);
assert_eq!(result.gesture_type, Some(GestureType::Wave));
}
#[test]
fn test_classify_no_match_high_distance() {
let mut classifier = GestureClassifier::new(GestureConfig {
max_distance: 0.001, // very strict
..small_config()
});
classifier
.add_template(make_template(
"wave",
GestureType::Wave,
10,
4,
wave_pattern,
))
.unwrap();
// Random-ish sequence
let seq: Vec<Vec<f64>> = (0..10)
.map(|t| vec![t as f64 * 10.0, 0.0, 0.0, 0.0])
.collect();
let result = classifier.classify(&seq, 1, 0).unwrap();
assert!(!result.recognized);
assert!(result.gesture_type.is_none());
}
#[test]
fn test_dtw_identical_sequences() {
let seq: Vec<Vec<f64>> = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
let dist = dtw_distance(&seq, &seq, 3);
assert!(
dist < 1e-10,
"Identical sequences should have zero DTW distance"
);
}
#[test]
fn test_dtw_different_sequences() {
let a: Vec<Vec<f64>> = vec![vec![0.0], vec![0.0], vec![0.0]];
let b: Vec<Vec<f64>> = vec![vec![10.0], vec![10.0], vec![10.0]];
let dist = dtw_distance(&a, &b, 3);
assert!(
dist > 0.0,
"Different sequences should have non-zero DTW distance"
);
}
#[test]
fn test_dtw_time_warped() {
// Same shape but different speed
let a: Vec<Vec<f64>> = vec![vec![0.0], vec![1.0], vec![2.0], vec![3.0]];
let b: Vec<Vec<f64>> = vec![
vec![0.0],
vec![0.5],
vec![1.0],
vec![1.5],
vec![2.0],
vec![2.5],
vec![3.0],
];
let dist = dtw_distance(&a, &b, 4);
// DTW should be relatively small despite different lengths
assert!(dist < 2.0, "DTW should handle time warping, got {}", dist);
}
#[test]
fn test_euclidean_distance() {
let a = vec![0.0, 3.0];
let b = vec![4.0, 0.0];
let d = euclidean_distance(&a, &b);
assert!((d - 5.0).abs() < 1e-10);
}
#[test]
fn test_gesture_type_names() {
assert_eq!(GestureType::Wave.name(), "wave");
assert_eq!(GestureType::Push.name(), "push");
assert_eq!(GestureType::Circle.name(), "circle");
assert_eq!(GestureType::Custom.name(), "custom");
}
}