411 lines
14 KiB
Rust
411 lines
14 KiB
Rust
//! Tailgating detection — ADR-041 Category 2 Security module.
|
|
//!
|
|
//! Detects tailgating at doorways — two or more people passing through in rapid
|
|
//! succession — by looking for double-peaked (or multi-peaked) motion energy
|
|
//! envelopes. A single authorised passage produces one smooth energy peak; a
|
|
//! tailgater following closely produces a second peak within a configurable
|
|
//! inter-peak interval.
|
|
//!
|
|
//! The detector uses temporal clustering of motion energy peaks. When a peak
|
|
//! is detected (energy crosses above threshold then falls), a window opens.
|
|
//! If another peak occurs within the window, tailgating is flagged.
|
|
//!
|
|
//! Events: TAILGATE_DETECTED(230), SINGLE_PASSAGE(231), MULTI_PASSAGE(232).
|
|
//! Budget: L (<2 ms).
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use libm::fabsf;
|
|
#[cfg(feature = "std")]
|
|
fn fabsf(x: f32) -> f32 { x.abs() }
|
|
|
|
/// Motion energy threshold to consider a peak start.
|
|
const ENERGY_PEAK_THRESH: f32 = 2.0;
|
|
/// Energy must drop below this fraction of peak to end a peak.
|
|
const ENERGY_VALLEY_FRAC: f32 = 0.3;
|
|
/// Maximum inter-peak interval for tailgating (frames). Default: 3 seconds at 20 Hz.
|
|
const TAILGATE_WINDOW: u32 = 60;
|
|
/// Minimum peak energy to be considered a valid passage.
|
|
const MIN_PEAK_ENERGY: f32 = 1.5;
|
|
/// Cooldown after tailgate event (frames).
|
|
const COOLDOWN: u16 = 100;
|
|
/// Minimum frames a peak must last to be valid (debounce noise spikes).
|
|
const MIN_PEAK_FRAMES: u8 = 3;
|
|
/// Maximum peaks tracked in one passage window.
|
|
const MAX_PEAKS: usize = 8;
|
|
|
|
pub const EVENT_TAILGATE_DETECTED: i32 = 230;
|
|
pub const EVENT_SINGLE_PASSAGE: i32 = 231;
|
|
pub const EVENT_MULTI_PASSAGE: i32 = 232;
|
|
|
|
/// Peak detection state.
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum PeakState {
|
|
/// Waiting for energy to rise above threshold.
|
|
Idle,
|
|
/// Energy is above threshold — tracking a peak.
|
|
InPeak,
|
|
/// Peak ended, watching for another peak within window.
|
|
Watching,
|
|
}
|
|
|
|
/// Tailgating detector.
|
|
pub struct TailgateDetector {
|
|
state: PeakState,
|
|
/// Current peak's maximum energy.
|
|
peak_max: f32,
|
|
/// Frames spent in current peak.
|
|
peak_frames: u8,
|
|
/// Peaks detected in current passage window.
|
|
peaks_in_window: u8,
|
|
/// Peak energies recorded.
|
|
peak_energies: [f32; MAX_PEAKS],
|
|
/// Frames since last peak ended (for window timeout).
|
|
frames_since_peak: u32,
|
|
/// Total passages detected.
|
|
single_passages: u32,
|
|
/// Total tailgating events.
|
|
tailgate_count: u32,
|
|
/// Cooldowns.
|
|
cd_tailgate: u16,
|
|
cd_passage: u16,
|
|
frame_count: u32,
|
|
/// Previous motion energy (for slope detection).
|
|
prev_energy: f32,
|
|
/// Variance history for noise floor estimation.
|
|
var_accum: f32,
|
|
var_count: u32,
|
|
noise_floor: f32,
|
|
}
|
|
|
|
impl TailgateDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
state: PeakState::Idle,
|
|
peak_max: 0.0,
|
|
peak_frames: 0,
|
|
peaks_in_window: 0,
|
|
peak_energies: [0.0; MAX_PEAKS],
|
|
frames_since_peak: 0,
|
|
single_passages: 0,
|
|
tailgate_count: 0,
|
|
cd_tailgate: 0,
|
|
cd_passage: 0,
|
|
frame_count: 0,
|
|
prev_energy: 0.0,
|
|
var_accum: 0.0,
|
|
var_count: 0,
|
|
noise_floor: 0.5,
|
|
}
|
|
}
|
|
|
|
/// Process one frame. Returns `(event_id, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
motion_energy: f32,
|
|
_presence: i32,
|
|
_n_persons: i32,
|
|
variance: f32,
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
self.cd_tailgate = self.cd_tailgate.saturating_sub(1);
|
|
self.cd_passage = self.cd_passage.saturating_sub(1);
|
|
|
|
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
|
let mut ne = 0usize;
|
|
|
|
// Update noise floor estimate (exponential moving average of variance).
|
|
self.var_accum += variance;
|
|
self.var_count += 1;
|
|
if self.var_count >= 20 {
|
|
self.noise_floor = (self.var_accum / self.var_count as f32).max(0.1);
|
|
self.var_accum = 0.0;
|
|
self.var_count = 0;
|
|
}
|
|
|
|
let threshold = ENERGY_PEAK_THRESH.max(self.noise_floor * 3.0);
|
|
|
|
match self.state {
|
|
PeakState::Idle => {
|
|
if motion_energy > threshold {
|
|
self.state = PeakState::InPeak;
|
|
self.peak_max = motion_energy;
|
|
self.peak_frames = 1;
|
|
self.peaks_in_window = 0;
|
|
}
|
|
}
|
|
|
|
PeakState::InPeak => {
|
|
if motion_energy > self.peak_max {
|
|
self.peak_max = motion_energy;
|
|
}
|
|
self.peak_frames = self.peak_frames.saturating_add(1);
|
|
|
|
// Peak ends when energy drops below valley threshold.
|
|
if motion_energy < self.peak_max * ENERGY_VALLEY_FRAC {
|
|
if self.peak_frames >= MIN_PEAK_FRAMES && self.peak_max >= MIN_PEAK_ENERGY {
|
|
// Valid peak recorded.
|
|
let idx = self.peaks_in_window as usize;
|
|
if idx < MAX_PEAKS {
|
|
self.peak_energies[idx] = self.peak_max;
|
|
}
|
|
self.peaks_in_window += 1;
|
|
self.state = PeakState::Watching;
|
|
self.frames_since_peak = 0;
|
|
} else {
|
|
// Noise spike, reset.
|
|
self.state = PeakState::Idle;
|
|
}
|
|
self.peak_max = 0.0;
|
|
self.peak_frames = 0;
|
|
}
|
|
}
|
|
|
|
PeakState::Watching => {
|
|
self.frames_since_peak += 1;
|
|
|
|
// Check if a new peak is starting within window.
|
|
if motion_energy > threshold {
|
|
self.state = PeakState::InPeak;
|
|
self.peak_max = motion_energy;
|
|
self.peak_frames = 1;
|
|
return unsafe { &EVENTS[..0] };
|
|
}
|
|
|
|
// Window expired — evaluate passage.
|
|
if self.frames_since_peak >= TAILGATE_WINDOW {
|
|
if self.peaks_in_window >= 2 {
|
|
// Multiple peaks detected = tailgating.
|
|
if self.cd_tailgate == 0 && ne < 3 {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_TAILGATE_DETECTED, self.peaks_in_window as f32);
|
|
}
|
|
ne += 1;
|
|
self.cd_tailgate = COOLDOWN;
|
|
self.tailgate_count += 1;
|
|
}
|
|
|
|
// Also emit multi-passage.
|
|
if self.cd_passage == 0 && ne < 3 {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_MULTI_PASSAGE, self.peaks_in_window as f32);
|
|
}
|
|
ne += 1;
|
|
self.cd_passage = COOLDOWN;
|
|
}
|
|
} else if self.peaks_in_window == 1 {
|
|
// Single passage.
|
|
if self.cd_passage == 0 && ne < 3 {
|
|
unsafe {
|
|
EVENTS[ne] = (EVENT_SINGLE_PASSAGE, self.peak_energies[0]);
|
|
}
|
|
ne += 1;
|
|
self.cd_passage = COOLDOWN;
|
|
self.single_passages += 1;
|
|
}
|
|
}
|
|
|
|
// Reset for next passage.
|
|
self.state = PeakState::Idle;
|
|
self.peaks_in_window = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.prev_energy = motion_energy;
|
|
unsafe { &EVENTS[..ne] }
|
|
}
|
|
|
|
pub fn frame_count(&self) -> u32 { self.frame_count }
|
|
pub fn tailgate_count(&self) -> u32 { self.tailgate_count }
|
|
pub fn single_passages(&self) -> u32 { self.single_passages }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Simulate a passage: ramp energy up then down.
|
|
fn simulate_peak(det: &mut TailgateDetector, peak_energy: f32) -> Vec<(i32, f32)> {
|
|
let mut all_events = Vec::new();
|
|
// Ramp up over 5 frames.
|
|
for i in 1..=5 {
|
|
let e = peak_energy * (i as f32 / 5.0);
|
|
let ev = det.process_frame(e, 1, 1, 0.1);
|
|
all_events.extend_from_slice(ev);
|
|
}
|
|
// Ramp down over 5 frames.
|
|
for i in (0..5).rev() {
|
|
let e = peak_energy * (i as f32 / 5.0);
|
|
let ev = det.process_frame(e, 1, 1, 0.1);
|
|
all_events.extend_from_slice(ev);
|
|
}
|
|
all_events
|
|
}
|
|
|
|
#[test]
|
|
fn test_init() {
|
|
let det = TailgateDetector::new();
|
|
assert_eq!(det.frame_count(), 0);
|
|
assert_eq!(det.tailgate_count(), 0);
|
|
assert_eq!(det.single_passages(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_single_passage() {
|
|
let mut det = TailgateDetector::new();
|
|
// Stabilize noise floor.
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Single peak.
|
|
simulate_peak(&mut det, 5.0);
|
|
|
|
// Wait for window to expire.
|
|
let mut found_single = false;
|
|
for _ in 0..(TAILGATE_WINDOW + 10) {
|
|
let ev = det.process_frame(0.1, 0, 0, 0.05);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_SINGLE_PASSAGE {
|
|
found_single = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_single, "single passage should be detected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tailgate_detection() {
|
|
let mut det = TailgateDetector::new();
|
|
// Stabilize noise floor.
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// First peak (authorized person).
|
|
simulate_peak(&mut det, 5.0);
|
|
|
|
// Brief gap (< TAILGATE_WINDOW frames).
|
|
for _ in 0..10 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Second peak (tailgater).
|
|
simulate_peak(&mut det, 4.0);
|
|
|
|
// Wait for window to expire.
|
|
let mut found_tailgate = false;
|
|
for _ in 0..(TAILGATE_WINDOW + 10) {
|
|
let ev = det.process_frame(0.1, 0, 0, 0.05);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_TAILGATE_DETECTED {
|
|
found_tailgate = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_tailgate, "tailgating should be detected with two close peaks");
|
|
}
|
|
|
|
#[test]
|
|
fn test_widely_spaced_peaks_no_tailgate() {
|
|
let mut det = TailgateDetector::new();
|
|
// Stabilize.
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// First peak.
|
|
simulate_peak(&mut det, 5.0);
|
|
|
|
// Wait longer than tailgate window.
|
|
for _ in 0..(TAILGATE_WINDOW + 30) {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Second peak.
|
|
simulate_peak(&mut det, 5.0);
|
|
|
|
// Wait for evaluation.
|
|
let mut found_tailgate = false;
|
|
for _ in 0..(TAILGATE_WINDOW + 10) {
|
|
let ev = det.process_frame(0.1, 0, 0, 0.05);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_TAILGATE_DETECTED {
|
|
found_tailgate = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(!found_tailgate, "widely spaced peaks should not trigger tailgate");
|
|
}
|
|
|
|
#[test]
|
|
fn test_noise_spike_ignored() {
|
|
let mut det = TailgateDetector::new();
|
|
// Stabilize.
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Very brief spike (1 frame above threshold — below MIN_PEAK_FRAMES).
|
|
det.process_frame(5.0, 1, 1, 0.1);
|
|
det.process_frame(0.1, 0, 0, 0.05); // Immediately drop.
|
|
|
|
// Should not produce any passage events.
|
|
let mut any_passage = false;
|
|
for _ in 0..(TAILGATE_WINDOW + 10) {
|
|
let ev = det.process_frame(0.1, 0, 0, 0.05);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_SINGLE_PASSAGE || et == EVENT_TAILGATE_DETECTED {
|
|
any_passage = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(!any_passage, "noise spike should not trigger passage event");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_passage_event() {
|
|
let mut det = TailgateDetector::new();
|
|
// Stabilize.
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Three peaks in rapid succession.
|
|
simulate_peak(&mut det, 5.0);
|
|
for _ in 0..8 { det.process_frame(0.1, 0, 0, 0.05); }
|
|
simulate_peak(&mut det, 4.5);
|
|
for _ in 0..8 { det.process_frame(0.1, 0, 0, 0.05); }
|
|
simulate_peak(&mut det, 4.0);
|
|
|
|
let mut found_multi = false;
|
|
for _ in 0..(TAILGATE_WINDOW + 10) {
|
|
let ev = det.process_frame(0.1, 0, 0, 0.05);
|
|
for &(et, v) in ev {
|
|
if et == EVENT_MULTI_PASSAGE {
|
|
found_multi = true;
|
|
assert!(v >= 2.0, "multi passage should report 2+ peaks");
|
|
}
|
|
}
|
|
}
|
|
assert!(found_multi, "multi-passage event should fire with 3 rapid peaks");
|
|
}
|
|
|
|
#[test]
|
|
fn test_low_energy_ignored() {
|
|
let mut det = TailgateDetector::new();
|
|
for _ in 0..30 {
|
|
det.process_frame(0.1, 0, 0, 0.05);
|
|
}
|
|
|
|
// Below peak threshold.
|
|
for _ in 0..100 {
|
|
let ev = det.process_frame(0.5, 1, 1, 0.1);
|
|
for &(et, _) in ev {
|
|
assert_ne!(et, EVENT_TAILGATE_DETECTED);
|
|
assert_ne!(et, EVENT_SINGLE_PASSAGE);
|
|
}
|
|
}
|
|
}
|
|
}
|