use image::GrayImage;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TemporalConfig {
pub hysteresis_enabled: bool,
pub hysteresis_margin: u8,
pub frame_blend_enabled: bool,
pub frame_blend_alpha: f32,
pub dot_filter_enabled: bool,
pub dot_filter_alpha: f32,
}
impl Default for TemporalConfig {
fn default() -> Self {
Self {
hysteresis_enabled: true,
hysteresis_margin: 10,
frame_blend_enabled: false,
frame_blend_alpha: 0.8,
dot_filter_enabled: false,
dot_filter_alpha: 0.5,
}
}
}
impl TemporalConfig {
#[must_use]
pub const fn video() -> Self {
Self {
hysteresis_enabled: true,
hysteresis_margin: 12,
frame_blend_enabled: true,
frame_blend_alpha: 0.85,
dot_filter_enabled: false,
dot_filter_alpha: 0.5,
}
}
#[must_use]
pub const fn webcam() -> Self {
Self {
hysteresis_enabled: true,
hysteresis_margin: 15,
frame_blend_enabled: true,
frame_blend_alpha: 0.7,
dot_filter_enabled: true,
dot_filter_alpha: 0.6,
}
}
#[must_use]
pub const fn animation() -> Self {
Self {
hysteresis_enabled: true,
hysteresis_margin: 8,
frame_blend_enabled: false,
frame_blend_alpha: 0.9,
dot_filter_enabled: false,
dot_filter_alpha: 0.7,
}
}
#[must_use]
pub const fn disabled() -> Self {
Self {
hysteresis_enabled: false,
hysteresis_margin: 0,
frame_blend_enabled: false,
frame_blend_alpha: 1.0,
dot_filter_enabled: false,
dot_filter_alpha: 1.0,
}
}
}
#[derive(Debug, Clone)]
pub struct HysteresisFilter {
previous_state: Option<Vec<bool>>,
margin: u8,
dimensions: Option<(u32, u32)>,
}
impl HysteresisFilter {
#[must_use]
pub const fn new(margin: u8) -> Self {
Self {
previous_state: None,
margin,
dimensions: None,
}
}
pub fn apply(&mut self, frame: &GrayImage, threshold: u8) -> GrayImage {
let width = frame.width();
let height = frame.height();
let pixel_count = (width * height) as usize;
if self.dimensions != Some((width, height)) {
self.previous_state = None;
self.dimensions = Some((width, height));
}
let high_thresh = threshold.saturating_add(self.margin);
let low_thresh = threshold.saturating_sub(self.margin);
let mut new_state = Vec::with_capacity(pixel_count);
let mut output = GrayImage::new(width, height);
match &self.previous_state {
None => {
for (i, pixel) in frame.pixels().enumerate() {
let is_on = pixel.0[0] >= threshold;
new_state.push(is_on);
let x = (i % width as usize) as u32;
let y = (i / width as usize) as u32;
output.put_pixel(x, y, image::Luma([if is_on { 255 } else { 0 }]));
}
}
Some(prev) => {
for (i, pixel) in frame.pixels().enumerate() {
let value = pixel.0[0];
let was_on = prev.get(i).copied().unwrap_or(false);
let is_on = if was_on {
value >= low_thresh
} else {
value > high_thresh
};
new_state.push(is_on);
let x = (i % width as usize) as u32;
let y = (i / width as usize) as u32;
output.put_pixel(x, y, image::Luma([if is_on { 255 } else { 0 }]));
}
}
}
self.previous_state = Some(new_state);
output
}
pub fn reset(&mut self) {
self.previous_state = None;
self.dimensions = None;
}
pub fn set_margin(&mut self, margin: u8) {
self.margin = margin;
}
#[must_use]
pub const fn margin(&self) -> u8 {
self.margin
}
}
#[derive(Debug, Clone)]
pub struct FrameBlender {
history: Option<Vec<f32>>,
alpha: f32,
dimensions: Option<(u32, u32)>,
}
impl FrameBlender {
#[must_use]
pub fn new(alpha: f32) -> Self {
Self {
history: None,
alpha: alpha.clamp(0.0, 1.0),
dimensions: None,
}
}
pub fn blend(&mut self, frame: &GrayImage) -> GrayImage {
let width = frame.width();
let height = frame.height();
let pixel_count = (width * height) as usize;
if self.dimensions != Some((width, height)) {
self.history = None;
self.dimensions = Some((width, height));
}
let mut output = GrayImage::new(width, height);
match &mut self.history {
None => {
let mut hist = Vec::with_capacity(pixel_count);
for (i, pixel) in frame.pixels().enumerate() {
let value = f32::from(pixel.0[0]);
hist.push(value);
let x = (i % width as usize) as u32;
let y = (i / width as usize) as u32;
output.put_pixel(x, y, image::Luma([pixel.0[0]]));
}
self.history = Some(hist);
}
Some(hist) => {
let inv_alpha = 1.0 - self.alpha;
for (i, pixel) in frame.pixels().enumerate() {
let current = f32::from(pixel.0[0]);
let prev = hist.get(i).copied().unwrap_or(current);
let blended = self.alpha * current + inv_alpha * prev;
hist[i] = blended;
let x = (i % width as usize) as u32;
let y = (i / width as usize) as u32;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
output.put_pixel(x, y, image::Luma([blended.round() as u8]));
}
}
}
output
}
pub fn reset(&mut self) {
self.history = None;
self.dimensions = None;
}
pub fn set_alpha(&mut self, alpha: f32) {
self.alpha = alpha.clamp(0.0, 1.0);
}
#[must_use]
pub const fn alpha(&self) -> f32 {
self.alpha
}
}
#[derive(Debug, Clone)]
pub struct DotTemporalFilter {
confidence: Option<Vec<f32>>,
alpha: f32,
width: usize,
height: usize,
}
impl DotTemporalFilter {
#[must_use]
pub fn new(alpha: f32, width: usize, height: usize) -> Self {
Self {
confidence: None,
alpha: alpha.clamp(0.0, 1.0),
width,
height,
}
}
pub fn filter(&mut self, dots: &[bool]) -> Vec<bool> {
let expected_len = self.width * self.height;
if self.confidence.is_none() || self.confidence.as_ref().is_some_and(|c| c.len() != expected_len) {
self.confidence = Some(vec![0.5; expected_len]);
}
let conf = self.confidence.as_mut().unwrap();
let inv_alpha = 1.0 - self.alpha;
dots.iter()
.enumerate()
.map(|(i, &is_on)| {
if i < conf.len() {
let target = if is_on { 1.0 } else { 0.0 };
conf[i] = self.alpha * target + inv_alpha * conf[i];
conf[i] >= 0.5
} else {
is_on
}
})
.collect()
}
pub fn reset(&mut self) {
self.confidence = None;
}
pub fn resize(&mut self, width: usize, height: usize) {
if self.width != width || self.height != height {
self.width = width;
self.height = height;
self.confidence = None;
}
}
pub fn set_alpha(&mut self, alpha: f32) {
self.alpha = alpha.clamp(0.0, 1.0);
}
#[must_use]
pub const fn alpha(&self) -> f32 {
self.alpha
}
}
#[derive(Debug, Clone)]
pub struct TemporalCoherence {
config: TemporalConfig,
hysteresis: HysteresisFilter,
blender: FrameBlender,
dot_filter: Option<DotTemporalFilter>,
}
impl TemporalCoherence {
#[must_use]
pub fn new(config: TemporalConfig) -> Self {
Self {
hysteresis: HysteresisFilter::new(config.hysteresis_margin),
blender: FrameBlender::new(config.frame_blend_alpha),
dot_filter: None,
config,
}
}
pub fn process_grayscale(&mut self, frame: &GrayImage, threshold: u8) -> GrayImage {
let blended = if self.config.frame_blend_enabled {
self.blender.blend(frame)
} else {
frame.clone()
};
if self.config.hysteresis_enabled {
self.hysteresis.apply(&blended, threshold)
} else {
let mut output = GrayImage::new(blended.width(), blended.height());
for (i, pixel) in blended.pixels().enumerate() {
let is_on = pixel.0[0] >= threshold;
let x = (i % blended.width() as usize) as u32;
let y = (i / blended.width() as usize) as u32;
output.put_pixel(x, y, image::Luma([if is_on { 255 } else { 0 }]));
}
output
}
}
pub fn process_dots(&mut self, dots: &[bool], width: usize, height: usize) -> Vec<bool> {
if !self.config.dot_filter_enabled {
return dots.to_vec();
}
match &mut self.dot_filter {
Some(filter) => {
filter.resize(width, height);
}
None => {
self.dot_filter = Some(DotTemporalFilter::new(
self.config.dot_filter_alpha,
width,
height,
));
}
}
self.dot_filter.as_mut().unwrap().filter(dots)
}
pub fn set_config(&mut self, config: TemporalConfig) {
self.hysteresis.set_margin(config.hysteresis_margin);
self.blender.set_alpha(config.frame_blend_alpha);
if let Some(ref mut filter) = self.dot_filter {
filter.set_alpha(config.dot_filter_alpha);
}
self.config = config;
}
#[must_use]
pub const fn config(&self) -> &TemporalConfig {
&self.config
}
pub fn reset(&mut self) {
self.hysteresis.reset();
self.blender.reset();
if let Some(ref mut filter) = self.dot_filter {
filter.reset();
}
}
}
#[must_use]
pub fn measure_flicker(frame1: &[bool], frame2: &[bool]) -> f64 {
if frame1.is_empty() || frame2.is_empty() {
return 0.0;
}
let len = frame1.len().min(frame2.len());
let changes = frame1
.iter()
.zip(frame2.iter())
.take(len)
.filter(|(a, b)| a != b)
.count();
changes as f64 / len as f64
}
#[must_use]
pub fn average_flicker(frames: &[Vec<bool>]) -> f64 {
if frames.len() < 2 {
return 0.0;
}
let total_flicker: f64 = frames
.windows(2)
.map(|pair| measure_flicker(&pair[0], &pair[1]))
.sum();
total_flicker / (frames.len() - 1) as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_temporal_config_presets() {
let default = TemporalConfig::default();
assert!(default.hysteresis_enabled);
assert!(!default.frame_blend_enabled);
let video = TemporalConfig::video();
assert!(video.hysteresis_enabled);
assert!(video.frame_blend_enabled);
let webcam = TemporalConfig::webcam();
assert!(webcam.dot_filter_enabled);
let disabled = TemporalConfig::disabled();
assert!(!disabled.hysteresis_enabled);
assert!(!disabled.frame_blend_enabled);
assert!(!disabled.dot_filter_enabled);
}
#[test]
fn test_hysteresis_filter_first_frame() {
let mut filter = HysteresisFilter::new(10);
let frame = GrayImage::from_fn(4, 4, |x, _| {
image::Luma([if x < 2 { 100 } else { 150 }])
});
let result = filter.apply(&frame, 128);
assert_eq!(result.get_pixel(0, 0).0[0], 0); assert_eq!(result.get_pixel(2, 0).0[0], 255); }
#[test]
fn test_hysteresis_filter_stability() {
let mut filter = HysteresisFilter::new(20);
let frame1 = GrayImage::from_fn(1, 1, |_, _| image::Luma([140]));
let result1 = filter.apply(&frame1, 128);
assert_eq!(result1.get_pixel(0, 0).0[0], 255);
let frame2 = GrayImage::from_fn(1, 1, |_, _| image::Luma([115]));
let result2 = filter.apply(&frame2, 128);
assert_eq!(result2.get_pixel(0, 0).0[0], 255);
let frame3 = GrayImage::from_fn(1, 1, |_, _| image::Luma([105]));
let result3 = filter.apply(&frame3, 128);
assert_eq!(result3.get_pixel(0, 0).0[0], 0); }
#[test]
fn test_frame_blender() {
let mut blender = FrameBlender::new(0.5);
let frame1 = GrayImage::from_fn(2, 2, |_, _| image::Luma([100]));
let result1 = blender.blend(&frame1);
assert_eq!(result1.get_pixel(0, 0).0[0], 100);
let frame2 = GrayImage::from_fn(2, 2, |_, _| image::Luma([200]));
let result2 = blender.blend(&frame2);
assert_eq!(result2.get_pixel(0, 0).0[0], 150);
}
#[test]
fn test_dot_temporal_filter() {
let mut filter = DotTemporalFilter::new(0.5, 4, 1);
let frame1 = vec![false, false, false, false];
let result1 = filter.filter(&frame1);
assert!(result1.iter().all(|&d| !d));
let frame2 = vec![true, true, true, true];
let result2 = filter.filter(&frame2);
assert!(result2.iter().all(|&d| d));
}
#[test]
fn test_measure_flicker() {
let frame1 = vec![true, false, true, false];
let frame2 = vec![true, true, true, false];
let flicker = measure_flicker(&frame1, &frame2);
assert!((flicker - 0.25).abs() < 0.001);
}
#[test]
fn test_average_flicker() {
let frames = vec![
vec![true, false, true, false],
vec![true, true, true, false], vec![false, true, false, false], ];
let avg = average_flicker(&frames);
assert!((avg - 0.375).abs() < 0.001);
}
#[test]
fn test_temporal_coherence_full_pipeline() {
let config = TemporalConfig {
hysteresis_enabled: true,
hysteresis_margin: 10,
frame_blend_enabled: true,
frame_blend_alpha: 0.8,
dot_filter_enabled: false,
dot_filter_alpha: 0.5,
};
let mut coherence = TemporalCoherence::new(config);
let frame = GrayImage::from_fn(10, 10, |x, _| {
image::Luma([if x < 5 { 100 } else { 150 }])
});
let result = coherence.process_grayscale(&frame, 128);
assert_eq!(result.width(), 10);
assert_eq!(result.height(), 10);
}
}