mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
- ADR-042: Coherent Human Channel Imaging (non-CSI sensing protocol) with DDD domain model (6 bounded contexts) - 24 new WASM edge modules: medical (5), retail (5), security (5), building (5), industrial (5), exotic (8) - README: plain-language rewrites, moved detail sections below TOC, added edge module links to use case tables, firmware release docs - User guide: firmware release table, edge intelligence documentation - .gitignore: added rules for wasm, esp32 temp files, NVS binaries - WASM edge crate: cargo config, integration tests, module registry Co-Authored-By: claude-flow <ruv@ruv.net>
505 lines
18 KiB
Rust
505 lines
18 KiB
Rust
//! Shelf engagement detection — ADR-041 Category 4: Retail & Hospitality.
|
|
//!
|
|
//! Detects customers stopping near shelving using CSI phase perturbation analysis.
|
|
//! Low translational motion + high-frequency phase perturbation indicates someone
|
|
//! standing still but interacting with products (reaching, examining).
|
|
//!
|
|
//! Engagement classification:
|
|
//! - Browse: < 5 seconds of engagement
|
|
//! - Consider: 5-30 seconds of engagement
|
|
//! - Deep engagement: > 30 seconds of engagement
|
|
//!
|
|
//! Events (440-series):
|
|
//! - `SHELF_BROWSE(440)`: Short browsing event detected
|
|
//! - `SHELF_CONSIDER(441)`: Medium consideration event
|
|
//! - `SHELF_ENGAGE(442)`: Deep engagement event
|
|
//! - `REACH_DETECTED(443)`: Reaching gesture detected (high-freq phase burst)
|
|
//!
|
|
//! Host API used: presence, motion energy, variance, phase.
|
|
|
|
use crate::vendor_common::{CircularBuffer, Ema};
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use libm::{fabsf, sqrtf};
|
|
#[cfg(feature = "std")]
|
|
fn fabsf(x: f32) -> f32 { x.abs() }
|
|
#[cfg(feature = "std")]
|
|
fn sqrtf(x: f32) -> f32 { x.sqrt() }
|
|
|
|
// ── Event IDs ─────────────────────────────────────────────────────────────────
|
|
|
|
pub const EVENT_SHELF_BROWSE: i32 = 440;
|
|
pub const EVENT_SHELF_CONSIDER: i32 = 441;
|
|
pub const EVENT_SHELF_ENGAGE: i32 = 442;
|
|
pub const EVENT_REACH_DETECTED: i32 = 443;
|
|
|
|
// ── Configuration constants ──────────────────────────────────────────────────
|
|
|
|
/// Maximum subcarriers.
|
|
const MAX_SC: usize = 32;
|
|
|
|
/// Frame rate assumption (Hz).
|
|
const FRAME_RATE: f32 = 20.0;
|
|
|
|
/// Browse threshold in seconds.
|
|
const BROWSE_THRESH_S: f32 = 5.0;
|
|
/// Consider threshold in seconds.
|
|
const CONSIDER_THRESH_S: f32 = 30.0;
|
|
|
|
/// Browse threshold in frames.
|
|
const BROWSE_THRESH_FRAMES: u32 = (BROWSE_THRESH_S * FRAME_RATE) as u32;
|
|
/// Consider threshold in frames.
|
|
const CONSIDER_THRESH_FRAMES: u32 = (CONSIDER_THRESH_S * FRAME_RATE) as u32;
|
|
|
|
/// Motion energy threshold for "standing still" (low translational motion).
|
|
const STILL_MOTION_THRESH: f32 = 0.08;
|
|
|
|
/// High-frequency phase perturbation threshold (indicates hand/arm movement).
|
|
const PHASE_PERTURBATION_THRESH: f32 = 0.04;
|
|
|
|
/// Reach detection: high-frequency phase burst above this threshold.
|
|
const REACH_BURST_THRESH: f32 = 0.15;
|
|
|
|
/// Minimum frames of stillness before engagement counting starts.
|
|
const STILL_DEBOUNCE: u32 = 10;
|
|
|
|
/// Cooldown frames after emitting an engagement event.
|
|
const ENGAGEMENT_COOLDOWN: u16 = 60;
|
|
|
|
/// EMA alpha for phase perturbation smoothing.
|
|
const PERTURBATION_EMA_ALPHA: f32 = 0.2;
|
|
|
|
/// EMA alpha for motion smoothing.
|
|
const MOTION_EMA_ALPHA: f32 = 0.15;
|
|
|
|
/// Phase history depth for high-frequency analysis (0.5 s at 20 Hz).
|
|
const PHASE_HISTORY: usize = 10;
|
|
|
|
/// Maximum events per frame.
|
|
const MAX_EVENTS: usize = 4;
|
|
|
|
// ── Engagement State ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum EngagementLevel {
|
|
/// No engagement (passing by or absent).
|
|
None,
|
|
/// Brief browsing (< 5s).
|
|
Browse,
|
|
/// Considering product (5-30s).
|
|
Consider,
|
|
/// Deep engagement (> 30s).
|
|
DeepEngage,
|
|
}
|
|
|
|
// ── Shelf Engagement Detector ───────────────────────────────────────────────
|
|
|
|
/// Detects and classifies customer shelf engagement from CSI data.
|
|
pub struct ShelfEngagementDetector {
|
|
/// Previous phase values for perturbation calculation.
|
|
prev_phases: [f32; MAX_SC],
|
|
/// Phase perturbation EMA (high-frequency component).
|
|
perturbation_ema: Ema,
|
|
/// Motion energy EMA.
|
|
motion_ema: Ema,
|
|
/// Phase difference history for burst detection.
|
|
phase_diff_history: CircularBuffer<PHASE_HISTORY>,
|
|
/// Whether previous phases are initialized.
|
|
phase_init: bool,
|
|
/// Consecutive frames of "still + perturbation" (engagement).
|
|
engagement_frames: u32,
|
|
/// Consecutive frames of stillness (before engagement counting).
|
|
still_frames: u32,
|
|
/// Current engagement level.
|
|
level: EngagementLevel,
|
|
/// Previous emitted engagement level (avoid duplicate events).
|
|
prev_emitted_level: EngagementLevel,
|
|
/// Cooldown counter.
|
|
cooldown: u16,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
/// Total browsing events.
|
|
total_browse: u32,
|
|
/// Total consider events.
|
|
total_consider: u32,
|
|
/// Total deep engagement events.
|
|
total_engage: u32,
|
|
/// Total reach detections.
|
|
total_reaches: u32,
|
|
/// Number of subcarriers last frame.
|
|
n_sc: usize,
|
|
}
|
|
|
|
impl ShelfEngagementDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
prev_phases: [0.0; MAX_SC],
|
|
perturbation_ema: Ema::new(PERTURBATION_EMA_ALPHA),
|
|
motion_ema: Ema::new(MOTION_EMA_ALPHA),
|
|
phase_diff_history: CircularBuffer::new(),
|
|
phase_init: false,
|
|
engagement_frames: 0,
|
|
still_frames: 0,
|
|
level: EngagementLevel::None,
|
|
prev_emitted_level: EngagementLevel::None,
|
|
cooldown: 0,
|
|
frame_count: 0,
|
|
total_browse: 0,
|
|
total_consider: 0,
|
|
total_engage: 0,
|
|
total_reaches: 0,
|
|
n_sc: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one CSI frame.
|
|
///
|
|
/// - `presence`: 1 if someone is present
|
|
/// - `motion_energy`: aggregate motion energy
|
|
/// - `variance`: mean subcarrier variance
|
|
/// - `phases`: per-subcarrier phase values
|
|
///
|
|
/// Returns event slice `&[(event_type, value)]`.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
presence: i32,
|
|
motion_energy: f32,
|
|
_variance: f32,
|
|
phases: &[f32],
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
|
|
let n_sc = phases.len().min(MAX_SC);
|
|
self.n_sc = n_sc;
|
|
|
|
let is_present = presence > 0;
|
|
let smoothed_motion = self.motion_ema.update(motion_energy);
|
|
|
|
if self.cooldown > 0 {
|
|
self.cooldown -= 1;
|
|
}
|
|
|
|
// Initialize previous phases.
|
|
if !self.phase_init && n_sc > 0 {
|
|
for i in 0..n_sc {
|
|
self.prev_phases[i] = phases[i];
|
|
}
|
|
self.phase_init = true;
|
|
return &[];
|
|
}
|
|
|
|
// Compute high-frequency phase perturbation.
|
|
// This measures small rapid phase changes (hand/arm movements near shelf)
|
|
// distinct from large translational phase shifts (walking).
|
|
let mut perturbation = 0.0f32;
|
|
if n_sc > 0 {
|
|
// Compute per-subcarrier phase difference, then take std dev.
|
|
let mut diffs = [0.0f32; MAX_SC];
|
|
let mut diff_mean = 0.0f32;
|
|
for i in 0..n_sc {
|
|
diffs[i] = phases[i] - self.prev_phases[i];
|
|
diff_mean += diffs[i];
|
|
}
|
|
diff_mean /= n_sc as f32;
|
|
|
|
// Variance of phase differences (high = reaching/grabbing, low = still/walking).
|
|
let mut diff_var = 0.0f32;
|
|
for i in 0..n_sc {
|
|
let d = diffs[i] - diff_mean;
|
|
diff_var += d * d;
|
|
}
|
|
diff_var /= n_sc as f32;
|
|
perturbation = sqrtf(diff_var);
|
|
|
|
// Update previous phases.
|
|
for i in 0..n_sc {
|
|
self.prev_phases[i] = phases[i];
|
|
}
|
|
}
|
|
|
|
let smoothed_perturbation = self.perturbation_ema.update(perturbation);
|
|
self.phase_diff_history.push(perturbation);
|
|
|
|
// Build events.
|
|
static mut EVENTS: [(i32, f32); MAX_EVENTS] = [(0, 0.0); MAX_EVENTS];
|
|
let mut ne = 0usize;
|
|
|
|
if !is_present {
|
|
// No one present: end any engagement.
|
|
if self.level != EngagementLevel::None {
|
|
// Emit final engagement classification.
|
|
ne = self.emit_engagement_end(ne);
|
|
}
|
|
self.engagement_frames = 0;
|
|
self.still_frames = 0;
|
|
self.level = EngagementLevel::None;
|
|
self.prev_emitted_level = EngagementLevel::None;
|
|
unsafe { return &EVENTS[..ne]; }
|
|
}
|
|
|
|
// Detect stillness (low translational motion).
|
|
if smoothed_motion < STILL_MOTION_THRESH {
|
|
self.still_frames += 1;
|
|
} else {
|
|
// Moving: reset engagement.
|
|
if self.level != EngagementLevel::None && self.engagement_frames > 0 {
|
|
ne = self.emit_engagement_end(ne);
|
|
}
|
|
self.still_frames = 0;
|
|
self.engagement_frames = 0;
|
|
self.level = EngagementLevel::None;
|
|
self.prev_emitted_level = EngagementLevel::None;
|
|
unsafe { return &EVENTS[..ne]; }
|
|
}
|
|
|
|
// Only start engagement counting after debounce.
|
|
if self.still_frames >= STILL_DEBOUNCE && smoothed_perturbation > PHASE_PERTURBATION_THRESH {
|
|
self.engagement_frames += 1;
|
|
|
|
// Classify engagement level.
|
|
if self.engagement_frames >= CONSIDER_THRESH_FRAMES {
|
|
self.level = EngagementLevel::DeepEngage;
|
|
} else if self.engagement_frames >= BROWSE_THRESH_FRAMES {
|
|
self.level = EngagementLevel::Consider;
|
|
} else {
|
|
self.level = EngagementLevel::Browse;
|
|
}
|
|
|
|
// Emit on level upgrade.
|
|
if self.level != self.prev_emitted_level && self.cooldown == 0 {
|
|
let (event_id, duration) = match self.level {
|
|
EngagementLevel::Browse => {
|
|
self.total_browse += 1;
|
|
(EVENT_SHELF_BROWSE, self.engagement_frames as f32 / FRAME_RATE)
|
|
}
|
|
EngagementLevel::Consider => {
|
|
self.total_consider += 1;
|
|
(EVENT_SHELF_CONSIDER, self.engagement_frames as f32 / FRAME_RATE)
|
|
}
|
|
EngagementLevel::DeepEngage => {
|
|
self.total_engage += 1;
|
|
(EVENT_SHELF_ENGAGE, self.engagement_frames as f32 / FRAME_RATE)
|
|
}
|
|
EngagementLevel::None => (0, 0.0),
|
|
};
|
|
|
|
if event_id != 0 && ne < MAX_EVENTS {
|
|
unsafe {
|
|
EVENTS[ne] = (event_id, duration);
|
|
}
|
|
ne += 1;
|
|
self.prev_emitted_level = self.level;
|
|
self.cooldown = ENGAGEMENT_COOLDOWN;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reach detection: sudden high-frequency phase burst while still.
|
|
if self.still_frames > STILL_DEBOUNCE && perturbation > REACH_BURST_THRESH && ne < MAX_EVENTS {
|
|
self.total_reaches += 1;
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_REACH_DETECTED, perturbation);
|
|
}
|
|
ne += 1;
|
|
}
|
|
|
|
unsafe { &EVENTS[..ne] }
|
|
}
|
|
|
|
/// Emit engagement end event based on current level.
|
|
fn emit_engagement_end(&self, ne: usize) -> usize {
|
|
// The engagement classification was already emitted during the session.
|
|
// We could emit a summary here, but to stay within budget we just return.
|
|
ne
|
|
}
|
|
|
|
/// Get current engagement level.
|
|
pub fn engagement_level(&self) -> EngagementLevel {
|
|
self.level
|
|
}
|
|
|
|
/// Get engagement duration in seconds.
|
|
pub fn engagement_duration_s(&self) -> f32 {
|
|
self.engagement_frames as f32 / FRAME_RATE
|
|
}
|
|
|
|
/// Get total browse events.
|
|
pub fn total_browse_events(&self) -> u32 {
|
|
self.total_browse
|
|
}
|
|
|
|
/// Get total consider events.
|
|
pub fn total_consider_events(&self) -> u32 {
|
|
self.total_consider
|
|
}
|
|
|
|
/// Get total deep engagement events.
|
|
pub fn total_engage_events(&self) -> u32 {
|
|
self.total_engage
|
|
}
|
|
|
|
/// Get total reach detections.
|
|
pub fn total_reach_events(&self) -> u32 {
|
|
self.total_reaches
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_init_state() {
|
|
let se = ShelfEngagementDetector::new();
|
|
assert_eq!(se.engagement_level(), EngagementLevel::None);
|
|
assert!(se.engagement_duration_s() < 0.001);
|
|
assert_eq!(se.total_browse_events(), 0);
|
|
assert_eq!(se.total_consider_events(), 0);
|
|
assert_eq!(se.total_engage_events(), 0);
|
|
assert_eq!(se.total_reach_events(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_presence_no_engagement() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
let phases = [0.0f32; 16];
|
|
for _ in 0..200 {
|
|
let events = se.process_frame(0, 0.0, 0.0, &phases);
|
|
for &(et, _) in events {
|
|
assert!(
|
|
et != EVENT_SHELF_BROWSE && et != EVENT_SHELF_CONSIDER && et != EVENT_SHELF_ENGAGE,
|
|
"no engagement events without presence"
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(se.engagement_level(), EngagementLevel::None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_walking_past_no_engagement() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
// Initialize phases.
|
|
let init_phases = [0.0f32; 16];
|
|
se.process_frame(1, 0.5, 0.1, &init_phases);
|
|
|
|
// High motion (walking) should not trigger engagement.
|
|
for _ in 0..200 {
|
|
let phases: [f32; 16] = core::array::from_fn(|i| (i as f32) * 0.1);
|
|
se.process_frame(1, 0.5, 0.1, &phases);
|
|
}
|
|
assert_eq!(se.engagement_level(), EngagementLevel::None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_browse_detection() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
// Init with baseline phases.
|
|
let init_phases = [0.0f32; 16];
|
|
se.process_frame(1, 0.01, 0.01, &init_phases);
|
|
|
|
let mut browse_detected = false;
|
|
// Simulate standing still with spatially diverse phase perturbations.
|
|
// The key: each frame's per-subcarrier phase must vary enough that
|
|
// the std-dev of (phases[i] - prev_phases[i]) exceeds PHASE_PERTURBATION_THRESH.
|
|
for frame in 0..(BROWSE_THRESH_FRAMES + STILL_DEBOUNCE + 10) {
|
|
let mut phases = [0.0f32; 16];
|
|
for i in 0..16 {
|
|
// Alternating sign pattern with frame-varying magnitude
|
|
// produces high spatial variance in frame-to-frame differences.
|
|
let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
|
|
let mag = 0.15 * (1.0 + (frame as f32 * 0.5).sin());
|
|
phases[i] = sign * mag * (i as f32 * 0.3 + 0.1);
|
|
}
|
|
let events = se.process_frame(1, 0.02, 0.03, &phases);
|
|
for &(et, _) in events {
|
|
if et == EVENT_SHELF_BROWSE {
|
|
browse_detected = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(browse_detected, "browse event should be detected for short engagement");
|
|
}
|
|
|
|
#[test]
|
|
fn test_reach_detection() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
let init_phases = [0.0f32; 16];
|
|
se.process_frame(1, 0.01, 0.01, &init_phases);
|
|
|
|
// Build up stillness.
|
|
for _ in 0..STILL_DEBOUNCE + 5 {
|
|
se.process_frame(1, 0.02, 0.01, &[0.0f32; 16]);
|
|
}
|
|
|
|
let mut reach_detected = false;
|
|
// Sudden large perturbation (reach burst).
|
|
let mut reach_phases = [0.0f32; 16];
|
|
for i in 0..16 {
|
|
reach_phases[i] = if i % 2 == 0 { 0.5 } else { -0.5 };
|
|
}
|
|
let events = se.process_frame(1, 0.02, 0.05, &reach_phases);
|
|
for &(et, _) in events {
|
|
if et == EVENT_REACH_DETECTED {
|
|
reach_detected = true;
|
|
}
|
|
}
|
|
assert!(reach_detected, "reach should be detected from high phase burst");
|
|
}
|
|
|
|
#[test]
|
|
fn test_engagement_resets_on_departure() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
let init_phases = [0.0f32; 16];
|
|
se.process_frame(1, 0.01, 0.01, &init_phases);
|
|
|
|
// Build some engagement.
|
|
for frame in 0..50 {
|
|
let mut phases = [0.0f32; 16];
|
|
for i in 0..16 {
|
|
phases[i] = 0.1 * ((frame as f32 * 0.5 + i as f32).sin());
|
|
}
|
|
se.process_frame(1, 0.02, 0.03, &phases);
|
|
}
|
|
|
|
// Person leaves.
|
|
se.process_frame(0, 0.0, 0.0, &[0.0f32; 16]);
|
|
assert_eq!(se.engagement_level(), EngagementLevel::None);
|
|
assert!(se.engagement_duration_s() < 0.001);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_phases_no_panic() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
let empty: [f32; 0] = [];
|
|
let _events = se.process_frame(1, 0.1, 0.05, &empty);
|
|
// Should not panic.
|
|
}
|
|
|
|
#[test]
|
|
fn test_consider_level_upgrade() {
|
|
let mut se = ShelfEngagementDetector::new();
|
|
let init_phases = [0.0f32; 16];
|
|
se.process_frame(1, 0.01, 0.01, &init_phases);
|
|
|
|
let mut consider_detected = false;
|
|
// Simulate long engagement (> 30s = 600 frames + debounce).
|
|
for frame in 0..(CONSIDER_THRESH_FRAMES + STILL_DEBOUNCE + 10) {
|
|
let mut phases = [0.0f32; 16];
|
|
for i in 0..16 {
|
|
// Same spatially diverse pattern as browse test.
|
|
let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
|
|
let mag = 0.15 * (1.0 + (frame as f32 * 0.5).sin());
|
|
phases[i] = sign * mag * (i as f32 * 0.3 + 0.1);
|
|
}
|
|
let events = se.process_frame(1, 0.02, 0.03, &phases);
|
|
for &(et, _) in events {
|
|
if et == EVENT_SHELF_CONSIDER {
|
|
consider_detected = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(consider_detected, "consider event should fire after {} frames", CONSIDER_THRESH_FRAMES);
|
|
}
|
|
}
|