use crate::error::{CvError, CvResult};
const HOG_BINS: usize = 9;
const HOG_CELL: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Idle,
Walking,
Running,
Waving,
Clapping,
Jumping,
Falling,
Unknown,
}
impl Action {
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Walking => "walking",
Self::Running => "running",
Self::Waving => "waving",
Self::Clapping => "clapping",
Self::Jumping => "jumping",
Self::Falling => "falling",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone)]
pub struct ActionPrediction {
pub action: Action,
pub confidence: f32,
pub top_k: Vec<(Action, f32)>,
}
#[derive(Debug, Clone)]
pub struct ActionConfig {
pub window_frames: usize,
pub window_step: usize,
pub motion_threshold: f32,
pub top_k: usize,
}
impl Default for ActionConfig {
fn default() -> Self {
Self {
window_frames: 16,
window_step: 4,
motion_threshold: 5.0,
top_k: 3,
}
}
}
#[allow(clippy::cast_precision_loss)]
fn frame_diff_hog(diff: &[f32], width: usize, height: usize) -> Vec<f32> {
let cells_x = (width / HOG_CELL).max(1);
let cells_y = (height / HOG_CELL).max(1);
let num_cells = cells_x * cells_y;
let mut descriptor = vec![0.0f32; num_cells * HOG_BINS];
for cy in 0..cells_y {
for cx in 0..cells_x {
let x0 = cx * HOG_CELL;
let y0 = cy * HOG_CELL;
let cell_idx = cy * cells_x + cx;
let bin_offset = cell_idx * HOG_BINS;
for py in y0..(y0 + HOG_CELL).min(height) {
for px in x0..(x0 + HOG_CELL).min(width) {
let get = |x: usize, y: usize| -> f32 {
if x < width && y < height {
diff[y * width + x]
} else {
0.0
}
};
let gx = get(px.saturating_add(1), py) - get(px.saturating_sub(1), py);
let gy = get(px, py.saturating_add(1)) - get(px, py.saturating_sub(1));
let mag = (gx * gx + gy * gy).sqrt();
let angle = gy.atan2(gx);
let angle_pos = if angle < 0.0 {
angle + std::f32::consts::PI
} else {
angle
};
let bin = ((angle_pos / std::f32::consts::PI) * HOG_BINS as f32) as usize;
let bin = bin.min(HOG_BINS - 1);
descriptor[bin_offset + bin] += mag;
}
}
}
}
let l2: f32 = descriptor.iter().map(|&v| v * v).sum::<f32>().sqrt();
if l2 > 1e-6 {
for v in &mut descriptor {
*v /= l2;
}
}
descriptor
}
#[allow(clippy::cast_precision_loss)]
fn temporal_hog(frames: &[(Vec<f32>, usize, usize)]) -> Vec<f32> {
if frames.len() < 2 {
return Vec::new();
}
let (_, w, h) = &frames[0];
let width = *w;
let height = *h;
let descriptor_len = (width / HOG_CELL).max(1) * (height / HOG_CELL).max(1) * HOG_BINS;
let mut accumulator = vec![0.0f32; descriptor_len];
let mut count = 0usize;
for i in 1..frames.len() {
let (prev, pw, ph) = &frames[i - 1];
let (curr, cw, ch) = &frames[i];
if pw != cw || ph != ch || prev.len() != curr.len() {
continue;
}
let diff: Vec<f32> = prev
.iter()
.zip(curr.iter())
.map(|(&p, &c)| (c - p).abs())
.collect();
let hog = frame_diff_hog(&diff, width, height);
if hog.len() == descriptor_len {
for (acc, h) in accumulator.iter_mut().zip(hog.iter()) {
*acc += h;
}
count += 1;
}
}
if count > 0 {
for v in &mut accumulator {
*v /= count as f32;
}
}
let l2: f32 = accumulator.iter().map(|&v| v * v).sum::<f32>().sqrt();
if l2 > 1e-6 {
for v in &mut accumulator {
*v /= l2;
}
}
accumulator
}
struct ActionTemplate {
action: Action,
descriptor: Vec<f32>,
}
#[allow(clippy::cast_precision_loss)]
fn build_templates(len: usize) -> Vec<ActionTemplate> {
if len == 0 {
return Vec::new();
}
let uniform = |weight: f32| -> Vec<f32> {
let v = weight / len as f32;
vec![v; len]
};
let ramp_up =
|scale: f32| -> Vec<f32> { (0..len).map(|i| scale * (i as f32 / len as f32)).collect() };
let ramp_down = |scale: f32| -> Vec<f32> {
(0..len)
.map(|i| scale * (1.0 - i as f32 / len as f32))
.collect()
};
let mid_peak = |scale: f32| -> Vec<f32> {
(0..len)
.map(|i| {
let t = i as f32 / len as f32;
scale * (-((t - 0.5) * 4.0).powi(2)).exp()
})
.collect()
};
let l2_norm = |mut v: Vec<f32>| -> Vec<f32> {
let l2: f32 = v.iter().map(|&x| x * x).sum::<f32>().sqrt();
if l2 > 1e-6 {
for x in &mut v {
*x /= l2;
}
}
v
};
vec![
ActionTemplate {
action: Action::Idle,
descriptor: l2_norm(uniform(0.01)),
},
ActionTemplate {
action: Action::Walking,
descriptor: l2_norm(ramp_up(0.3)),
},
ActionTemplate {
action: Action::Running,
descriptor: l2_norm(ramp_down(0.8)),
},
ActionTemplate {
action: Action::Waving,
descriptor: l2_norm(mid_peak(0.6)),
},
ActionTemplate {
action: Action::Clapping,
descriptor: l2_norm(
(0..len)
.map(|i| if i % 3 == 0 { 0.7 } else { 0.1 })
.collect(),
),
},
ActionTemplate {
action: Action::Jumping,
descriptor: l2_norm(
(0..len)
.map(|i| {
let t = i as f32 / len as f32;
(-t * 3.0).exp()
})
.collect(),
),
},
ActionTemplate {
action: Action::Falling,
descriptor: l2_norm(
(0..len)
.map(|i| {
let t = i as f32 / len as f32;
(-t * 1.5).exp() * 0.9
})
.collect(),
),
},
]
}
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
let na: f32 = a.iter().map(|&x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|&x| x * x).sum::<f32>().sqrt();
if na < 1e-6 || nb < 1e-6 {
0.0
} else {
(dot / (na * nb)).clamp(-1.0, 1.0)
}
}
pub struct ActionRecognizer {
config: ActionConfig,
frame_buffer: std::collections::VecDeque<(Vec<f32>, usize, usize)>,
frames_pushed: usize,
}
impl ActionRecognizer {
#[must_use]
pub fn new(config: ActionConfig) -> Self {
let capacity = config.window_frames + 1;
Self {
config,
frame_buffer: std::collections::VecDeque::with_capacity(capacity),
frames_pushed: 0,
}
}
pub fn push_frame(&mut self, pixels: &[u8], width: usize, height: usize) {
if pixels.len() != width * height {
return;
}
let f32_pixels: Vec<f32> = pixels.iter().map(|&p| f32::from(p) / 255.0).collect();
if self.frame_buffer.len() >= self.config.window_frames {
self.frame_buffer.pop_front();
}
self.frame_buffer.push_back((f32_pixels, width, height));
self.frames_pushed += 1;
}
pub fn push_frame_f32(&mut self, pixels: &[f32], width: usize, height: usize) {
if pixels.len() != width * height {
return;
}
if self.frame_buffer.len() >= self.config.window_frames {
self.frame_buffer.pop_front();
}
self.frame_buffer
.push_back((pixels.to_vec(), width, height));
self.frames_pushed += 1;
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn mean_motion(&self) -> f32 {
if self.frame_buffer.len() < 2 {
return 0.0;
}
let mut total = 0.0f32;
let mut count = 0usize;
let frames: Vec<_> = self.frame_buffer.iter().collect();
for i in 1..frames.len() {
let (prev, _, _) = frames[i - 1];
let (curr, _, _) = frames[i];
if prev.len() == curr.len() {
let diff_sum: f32 = prev
.iter()
.zip(curr.iter())
.map(|(&p, &c)| (c - p).abs())
.sum();
total += diff_sum / prev.len().max(1) as f32;
count += 1;
}
}
if count == 0 {
0.0
} else {
total / count as f32 * 255.0 }
}
#[must_use]
pub fn is_ready(&self) -> bool {
self.frame_buffer.len() >= self.config.window_frames.min(2)
}
#[must_use]
pub fn descriptor(&self) -> Option<Vec<f32>> {
if self.frame_buffer.len() < 2 {
return None;
}
let frames: Vec<(Vec<f32>, usize, usize)> = self
.frame_buffer
.iter()
.map(|(p, w, h)| (p.clone(), *w, *h))
.collect();
let desc = temporal_hog(&frames);
if desc.is_empty() {
None
} else {
Some(desc)
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn predict(&self) -> Option<ActionPrediction> {
let desc = self.descriptor()?;
let motion = self.mean_motion();
if motion < self.config.motion_threshold {
return Some(ActionPrediction {
action: Action::Idle,
confidence: 1.0 - motion / self.config.motion_threshold.max(1.0),
top_k: vec![(Action::Idle, 1.0)],
});
}
let templates = build_templates(desc.len());
if templates.is_empty() {
return Some(ActionPrediction {
action: Action::Unknown,
confidence: 0.0,
top_k: vec![(Action::Unknown, 0.0)],
});
}
let mut scores: Vec<(Action, f32)> = templates
.into_iter()
.map(|t| {
let sim = cosine_similarity(&desc, &t.descriptor);
let score = (sim + 1.0) / 2.0;
(t.action, score)
})
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let top_k_n = self.config.top_k.min(scores.len());
let top_k: Vec<(Action, f32)> = scores[..top_k_n]
.iter()
.map(|(a, s)| (a.clone(), *s))
.collect();
let (best_action, best_score) = scores.into_iter().next().unwrap_or((Action::Unknown, 0.0));
Some(ActionPrediction {
action: best_action,
confidence: best_score,
top_k,
})
}
pub fn reset(&mut self) {
self.frame_buffer.clear();
self.frames_pushed = 0;
}
#[must_use]
pub fn buffer_size(&self) -> usize {
self.frame_buffer.len()
}
#[must_use]
pub fn frames_pushed(&self) -> usize {
self.frames_pushed
}
}
pub fn validate_frame(pixels: &[u8], width: usize, height: usize) -> CvResult<()> {
if width == 0 || height == 0 {
return Err(CvError::invalid_parameter("width/height", "must be > 0"));
}
if pixels.len() != width * height {
return Err(CvError::invalid_parameter(
"pixels",
"length must equal width * height",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const W: usize = 32;
const H: usize = 32;
fn make_frame(val: u8) -> Vec<u8> {
vec![val; W * H]
}
fn make_noisy_frame(base: u8, noise_step: u8) -> Vec<u8> {
(0..W * H)
.map(|i| base.wrapping_add((i as u8).wrapping_mul(noise_step)))
.collect()
}
#[test]
fn test_action_recognizer_new() {
let config = ActionConfig::default();
let recognizer = ActionRecognizer::new(config);
assert_eq!(recognizer.buffer_size(), 0);
assert_eq!(recognizer.frames_pushed(), 0);
}
#[test]
fn test_push_frame_increments_counter() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(100), W, H);
assert_eq!(rec.frames_pushed(), 1);
assert_eq!(rec.buffer_size(), 1);
}
#[test]
fn test_push_frame_invalid_size() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&[0u8; 10], W, H);
assert_eq!(rec.buffer_size(), 0);
}
#[test]
fn test_buffer_capped_at_window_frames() {
let mut rec = ActionRecognizer::new(ActionConfig {
window_frames: 4,
..Default::default()
});
for _ in 0..10 {
rec.push_frame(&make_frame(128), W, H);
}
assert_eq!(rec.buffer_size(), 4);
}
#[test]
fn test_not_ready_with_one_frame() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(100), W, H);
let config_min = ActionConfig {
window_frames: 16,
..Default::default()
};
let rec2 = ActionRecognizer::new(config_min);
assert!(!rec2.is_ready());
}
#[test]
fn test_is_ready_with_two_frames() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(100), W, H);
rec.push_frame(&make_frame(110), W, H);
assert!(rec.is_ready());
}
#[test]
fn test_idle_prediction_for_static_frames() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
for _ in 0..16 {
rec.push_frame(&make_frame(128), W, H);
}
let pred = rec.predict();
assert!(pred.is_some());
let pred = pred.expect("prediction should be Some");
assert_eq!(pred.action, Action::Idle);
}
#[test]
fn test_predict_returns_some_for_moving_frames() {
let mut rec = ActionRecognizer::new(ActionConfig {
motion_threshold: 0.1,
..Default::default()
});
for i in 0..16u8 {
rec.push_frame(&make_noisy_frame(i.wrapping_mul(10), 7), W, H);
}
let pred = rec.predict();
assert!(pred.is_some());
let pred = pred.expect("prediction should be Some");
assert!(pred.confidence >= 0.0 && pred.confidence <= 1.0);
}
#[test]
fn test_top_k_length() {
let mut rec = ActionRecognizer::new(ActionConfig {
top_k: 3,
motion_threshold: 0.0,
..Default::default()
});
for i in 0..16u8 {
rec.push_frame(&make_noisy_frame(i.wrapping_mul(15), 5), W, H);
}
if let Some(pred) = rec.predict() {
assert!(pred.top_k.len() <= 3);
}
}
#[test]
fn test_descriptor_none_for_empty_buffer() {
let rec = ActionRecognizer::new(ActionConfig::default());
assert!(rec.descriptor().is_none());
}
#[test]
fn test_descriptor_some_with_frames() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(100), W, H);
rec.push_frame(&make_frame(120), W, H);
assert!(rec.descriptor().is_some());
}
#[test]
fn test_mean_motion_zero_for_identical_frames() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(128), W, H);
rec.push_frame(&make_frame(128), W, H);
let motion = rec.mean_motion();
assert!(motion.abs() < 1e-3, "motion={motion}");
}
#[test]
fn test_mean_motion_positive_for_different_frames() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
rec.push_frame(&make_frame(0), W, H);
rec.push_frame(&make_frame(200), W, H);
let motion = rec.mean_motion();
assert!(motion > 0.0, "expected positive motion, got {motion}");
}
#[test]
fn test_reset_clears_buffer() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
for _ in 0..8 {
rec.push_frame(&make_frame(100), W, H);
}
rec.reset();
assert_eq!(rec.buffer_size(), 0);
assert_eq!(rec.frames_pushed(), 0);
}
#[test]
fn test_validate_frame_ok() {
let pixels = make_frame(100);
assert!(validate_frame(&pixels, W, H).is_ok());
}
#[test]
fn test_validate_frame_zero_dimension() {
let pixels = vec![0u8; 0];
assert!(validate_frame(&pixels, 0, H).is_err());
}
#[test]
fn test_validate_frame_size_mismatch() {
let pixels = vec![0u8; 10];
assert!(validate_frame(&pixels, W, H).is_err());
}
#[test]
fn test_action_name() {
assert_eq!(Action::Idle.name(), "idle");
assert_eq!(Action::Running.name(), "running");
assert_eq!(Action::Unknown.name(), "unknown");
}
#[test]
fn test_push_frame_f32() {
let mut rec = ActionRecognizer::new(ActionConfig::default());
let pixels = vec![0.5f32; W * H];
rec.push_frame_f32(&pixels, W, H);
assert_eq!(rec.buffer_size(), 1);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ActionLabel {
Static,
SlowMotion,
FastMotion,
Shake,
Pan,
Zoom,
Cut,
}
impl ActionLabel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Static => "static",
Self::SlowMotion => "slow_motion",
Self::FastMotion => "fast_motion",
Self::Shake => "shake",
Self::Pan => "pan",
Self::Zoom => "zoom",
Self::Cut => "cut",
}
}
}
impl std::fmt::Display for ActionLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionFeature {
pub mean_flow: f32,
pub max_flow: f32,
pub std_flow: f32,
pub motion_area_fraction: f32,
}
impl MotionFeature {
#[must_use]
pub fn new(mean_flow: f32, max_flow: f32, std_flow: f32, motion_area_fraction: f32) -> Self {
Self {
mean_flow,
max_flow,
std_flow,
motion_area_fraction,
}
}
}
#[must_use]
pub fn classify_motion(feat: &MotionFeature) -> ActionLabel {
if feat.max_flow > 80.0 {
return ActionLabel::Cut;
}
if feat.mean_flow < 0.5 {
return ActionLabel::Static;
}
let rel_std = if feat.mean_flow > 1e-6 {
feat.std_flow / feat.mean_flow
} else {
0.0
};
if rel_std > 2.0 {
return ActionLabel::Shake;
}
if feat.mean_flow < 3.0 {
return ActionLabel::SlowMotion;
}
if feat.mean_flow > 20.0 {
return ActionLabel::FastMotion;
}
if rel_std < 0.4 {
if feat.motion_area_fraction > 0.7 {
return ActionLabel::Zoom;
}
return ActionLabel::Pan;
}
ActionLabel::FastMotion
}
#[must_use]
pub fn recognize(motion_features: &[MotionFeature]) -> Vec<(usize, ActionLabel)> {
motion_features
.iter()
.enumerate()
.map(|(i, feat)| (i, classify_motion(feat)))
.collect()
}
#[derive(Debug, Clone)]
pub struct ActionSequence {
pub labels: Vec<(usize, ActionLabel)>,
}
impl ActionSequence {
#[must_use]
pub fn from_features(features: &[MotionFeature]) -> Self {
Self {
labels: recognize(features),
}
}
#[must_use]
pub fn new(labels: Vec<(usize, ActionLabel)>) -> Self {
Self { labels }
}
#[must_use]
pub fn smooth(&self, window: usize) -> Self {
if window <= 1 || self.labels.is_empty() {
return self.clone();
}
let half = window / 2;
let n = self.labels.len();
let smoothed: Vec<(usize, ActionLabel)> = (0..n)
.map(|i| {
let lo = i.saturating_sub(half);
let hi = (i + half + 1).min(n);
use std::collections::HashMap;
let mut votes: HashMap<ActionLabel, usize> = HashMap::new();
for (_, lbl) in &self.labels[lo..hi] {
*votes.entry(*lbl).or_insert(0) += 1;
}
let original = self.labels[i].1;
let winner = votes
.into_iter()
.max_by(|a, b| {
a.1.cmp(&b.1).then_with(|| {
if a.0 == original {
std::cmp::Ordering::Greater
} else if b.0 == original {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Equal
}
})
})
.map(|(lbl, _)| lbl)
.unwrap_or(original);
(self.labels[i].0, winner)
})
.collect();
Self { labels: smoothed }
}
#[must_use]
pub fn len(&self) -> usize {
self.labels.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.labels.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &(usize, ActionLabel)> {
self.labels.iter()
}
}
#[cfg(test)]
mod motion_tests {
use super::*;
fn feat(mean: f32, max: f32, std: f32, area: f32) -> MotionFeature {
MotionFeature::new(mean, max, std, area)
}
#[test]
fn test_classify_static() {
let f = feat(0.1, 1.0, 0.05, 0.01);
assert_eq!(classify_motion(&f), ActionLabel::Static);
}
#[test]
fn test_classify_cut() {
let f = feat(50.0, 100.0, 10.0, 0.9);
assert_eq!(classify_motion(&f), ActionLabel::Cut);
}
#[test]
fn test_classify_shake() {
let f = feat(2.0, 20.0, 6.0, 0.5);
assert_eq!(classify_motion(&f), ActionLabel::Shake);
}
#[test]
fn test_classify_slow_motion() {
let f = feat(1.5, 5.0, 0.3, 0.4);
assert_eq!(classify_motion(&f), ActionLabel::SlowMotion);
}
#[test]
fn test_classify_fast_motion() {
let f = feat(25.0, 60.0, 8.0, 0.6);
assert_eq!(classify_motion(&f), ActionLabel::FastMotion);
}
#[test]
fn test_classify_pan() {
let f = feat(8.0, 15.0, 1.5, 0.5); assert_eq!(classify_motion(&f), ActionLabel::Pan);
}
#[test]
fn test_classify_zoom() {
let f = feat(8.0, 15.0, 1.5, 0.85); assert_eq!(classify_motion(&f), ActionLabel::Zoom);
}
#[test]
fn test_recognize_empty() {
let result = recognize(&[]);
assert!(result.is_empty());
}
#[test]
fn test_recognize_preserves_indices() {
let features = vec![feat(0.1, 0.5, 0.02, 0.01), feat(25.0, 60.0, 8.0, 0.6)];
let result = recognize(&features);
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, 0);
assert_eq!(result[1].0, 1);
}
#[test]
fn test_recognize_mixed_sequence() {
let features = vec![
feat(0.1, 0.5, 0.02, 0.01), feat(1.5, 5.0, 0.3, 0.4), feat(50.0, 100.0, 10.0, 0.9), ];
let result = recognize(&features);
assert_eq!(result[0].1, ActionLabel::Static);
assert_eq!(result[1].1, ActionLabel::SlowMotion);
assert_eq!(result[2].1, ActionLabel::Cut);
}
#[test]
fn test_action_sequence_from_features() {
let features = vec![feat(0.1, 0.5, 0.02, 0.01)];
let seq = ActionSequence::from_features(&features);
assert_eq!(seq.len(), 1);
assert_eq!(seq.labels[0].1, ActionLabel::Static);
}
#[test]
fn test_smooth_window_one_unchanged() {
let labels = vec![
(0, ActionLabel::Static),
(1, ActionLabel::Cut),
(2, ActionLabel::Shake),
];
let seq = ActionSequence::new(labels.clone());
let smoothed = seq.smooth(1);
assert_eq!(smoothed.labels, labels);
}
#[test]
fn test_smooth_majority_vote() {
let labels = vec![
(0, ActionLabel::Static),
(1, ActionLabel::Static),
(2, ActionLabel::Cut), (3, ActionLabel::Static),
(4, ActionLabel::Static),
];
let seq = ActionSequence::new(labels);
let smoothed = seq.smooth(3);
assert_eq!(smoothed.labels[2].1, ActionLabel::Static);
}
#[test]
fn test_smooth_empty_sequence() {
let seq = ActionSequence::new(vec![]);
let smoothed = seq.smooth(5);
assert!(smoothed.is_empty());
}
#[test]
fn test_action_sequence_is_empty() {
let seq = ActionSequence::new(vec![]);
assert!(seq.is_empty());
assert_eq!(seq.len(), 0);
}
#[test]
fn test_action_label_display() {
assert_eq!(ActionLabel::Static.to_string(), "static");
assert_eq!(ActionLabel::Cut.to_string(), "cut");
assert_eq!(ActionLabel::Zoom.to_string(), "zoom");
}
#[test]
fn test_motion_feature_new() {
let f = MotionFeature::new(1.0, 5.0, 0.5, 0.3);
assert!((f.mean_flow - 1.0).abs() < 1e-6);
assert!((f.max_flow - 5.0).abs() < 1e-6);
assert!((f.std_flow - 0.5).abs() < 1e-6);
assert!((f.motion_area_fraction - 0.3).abs() < 1e-6);
}
}