use std::sync::OnceLock;
use rand::{
rngs::{SmallRng, StdRng},
Rng, SeedableRng,
};
use rand_distr::{Distribution, Normal};
use rayon::prelude::*;
use crate::viz::Rgb8Image;
use crate::{EventStream, EventStreamBuilder};
const LIN_LOG_THRESHOLD: f32 = 20.0;
const MIN_BANDWIDTH_FRACTION: f32 = 0.1;
const SHOT_NOISE_BRIGHT_FACTOR: f32 = 0.25;
pub const MAX_UPSAMPLE: usize = 64;
const MAX_UPSAMPLE_CEILING: usize = 4096;
const PIXEL_BLOCK: usize = 8192;
const PARALLEL_PIXEL_THRESHOLD: usize = 1 << 16;
const PARALLEL_SORT_THRESHOLD: usize = 1 << 16;
const NOISE_LUT_LEN: usize = 256;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Upsample {
Off,
Fixed(usize),
Adaptive { max_events_per_pixel: f32 },
}
impl Default for Upsample {
fn default() -> Self {
Self::Adaptive {
max_events_per_pixel: 1.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimulatorConfig {
pub pos_thres: f32,
pub neg_thres: f32,
pub sigma_thres: f32,
pub refractory_us: i64,
pub cutoff_hz: f32,
pub leak_rate_hz: f32,
pub shot_noise_rate_hz: f32,
pub seed: u64,
pub upsample: Upsample,
pub max_upsample: usize,
}
impl Default for SimulatorConfig {
fn default() -> Self {
Self {
pos_thres: 0.2,
neg_thres: 0.2,
sigma_thres: 0.03,
refractory_us: 100,
cutoff_hz: 200.0,
leak_rate_hz: 1.0,
shot_noise_rate_hz: 10.0,
seed: 0,
upsample: Upsample::default(),
max_upsample: MAX_UPSAMPLE,
}
}
}
impl SimulatorConfig {
pub fn ideal() -> Self {
Self {
sigma_thres: 0.0,
cutoff_hz: 0.0,
leak_rate_hz: 0.0,
shot_noise_rate_hz: 0.0,
refractory_us: 0,
upsample: Upsample::Off,
..Self::default()
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct PixelState {
pub(crate) log_ref: f32,
pub(crate) lowpass: f32,
pub(crate) thres_pos: f32,
pub(crate) thres_neg: f32,
pub(crate) last_t: i64,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct SimEvent {
pub(crate) t: i64,
pub(crate) x: u16,
pub(crate) y: u16,
pub(crate) positive: bool,
}
pub struct Simulator {
config: SimulatorConfig,
width: usize,
height: usize,
state: Vec<PixelState>,
log_now: Vec<f32>,
luma: Vec<f32>,
prev_log: Vec<f32>,
prev_luma: Vec<f32>,
t_previous: Option<i64>,
frame: u64,
blocks: Vec<Vec<SimEvent>>,
events: Vec<SimEvent>,
device: crate::accel::Device,
}
impl Simulator {
pub fn new(width: usize, height: usize, config: SimulatorConfig) -> Self {
let pixels = width * height;
let mut rng = StdRng::seed_from_u64(config.seed);
let sample = |rng: &mut StdRng, base: f32| -> Vec<f32> {
match Normal::new(0.0_f32, config.sigma_thres.max(0.0)) {
Ok(normal) if config.sigma_thres > 0.0 => (0..pixels)
.map(|_| (base + normal.sample(rng)).max(base * 0.1).max(1e-3))
.collect(),
_ => vec![base.max(1e-3); pixels],
}
};
let thres_pos = sample(&mut rng, config.pos_thres);
let thres_neg = sample(&mut rng, config.neg_thres);
let state = thres_pos
.into_iter()
.zip(thres_neg)
.map(|(thres_pos, thres_neg)| PixelState {
log_ref: 0.0,
lowpass: 0.0,
thres_pos,
thres_neg,
last_t: i64::MIN / 4,
})
.collect();
Self {
config,
width,
height,
state,
log_now: vec![0.0; pixels],
luma: vec![0.0; pixels],
prev_log: vec![0.0; pixels],
prev_luma: vec![0.0; pixels],
t_previous: None,
frame: 0,
blocks: vec![Vec::new(); pixels.div_ceil(PIXEL_BLOCK)],
events: Vec::new(),
device: crate::accel::Device::Cpu,
}
}
pub fn on_device(mut self, device: crate::accel::Device) -> Self {
self.device = device;
self
}
pub fn sensor_size(&self) -> (usize, usize) {
(self.width, self.height)
}
pub fn push_frame(&mut self, frame: &[f32], t_us: i64) -> EventStream {
assert_eq!(
frame.len(),
self.width * self.height,
"frame does not match the simulator's sensor size"
);
for (index, &value) in frame.iter().enumerate() {
let clamped = value.clamp(0.0, 1.0);
self.luma[index] = clamped;
self.log_now[index] = lin_log(clamped * 255.0);
}
let Some(t_previous) = self.t_previous else {
for (pixel, &log) in self.state.iter_mut().zip(&self.log_now) {
pixel.log_ref = log;
pixel.lowpass = log;
pixel.last_t = t_us;
}
self.advance(t_us);
return self.empty_stream();
};
let span = (t_us - t_previous).max(0);
let steps = self.upsample_steps(span);
self.simulate_interval(t_previous, span, steps);
self.advance(t_us);
self.collect_interval()
}
fn simulate_interval(&mut self, t_previous: i64, span: i64, steps: usize) {
if self.device == crate::accel::Device::Gpu && self.simulate_interval_on_gpu(t_previous, span, steps) {
return;
}
let width = self.width;
let pixels = self.state.len();
let config = &self.config;
let frame = self.frame;
let (prev_log, log_now) = (&self.prev_log, &self.log_now);
let (prev_luma, luma) = (&self.prev_luma, &self.luma);
let run = |block: usize, state: &mut [PixelState], out: &mut Vec<SimEvent>| {
out.clear();
let base = block * PIXEL_BLOCK;
let mut rng = SmallRng::seed_from_u64(block_seed(config.seed, frame, block));
for step in 1..=steps {
let alpha = step as f32 / steps as f32;
let t_start = t_previous + (span as f64 * (step - 1) as f64 / steps as f64) as i64;
let t_end = t_previous + (span as f64 * step as f64 / steps as f64) as i64;
let dt_s = ((t_end - t_start) as f64 / 1e6) as f32;
let noise = NoiseTable::new(config.shot_noise_rate_hz, dt_s);
let (mut x, mut y) = ((base % width) as u16, (base / width) as u16);
for (offset, pixel) in state.iter_mut().enumerate() {
let index = base + offset;
let target = prev_log[index] + (log_now[index] - prev_log[index]) * alpha;
let pixel_luma = prev_luma[index] + (luma[index] - prev_luma[index]) * alpha;
integrate_pixel(
pixel, x, y, target, pixel_luma, t_start, t_end, dt_s, config, &noise,
&mut rng, out,
);
x += 1;
if usize::from(x) == width {
x = 0;
y += 1;
}
}
}
};
if pixels < PARALLEL_PIXEL_THRESHOLD {
for (block, (state, out)) in self
.state
.chunks_mut(PIXEL_BLOCK)
.zip(self.blocks.iter_mut())
.enumerate()
{
run(block, state, out);
}
} else {
self.state
.par_chunks_mut(PIXEL_BLOCK)
.zip(self.blocks.par_iter_mut())
.enumerate()
.for_each(|(block, (state, out))| run(block, state, out));
}
}
#[cfg(feature = "gpu")]
fn simulate_interval_on_gpu(&mut self, t_previous: i64, span: i64, steps: usize) -> bool {
let bounds: Vec<i64> = (0..=steps)
.map(|step| t_previous + (span as f64 * step as f64 / steps as f64) as i64)
.collect();
let Some(events) = crate::accel::sim::run_interval(
self.width,
self.height,
&self.config,
self.frame,
&bounds,
&self.prev_log,
&self.log_now,
&self.prev_luma,
&self.luma,
&mut self.state,
) else {
return false;
};
for block in &mut self.blocks {
block.clear();
}
if let Some(first) = self.blocks.first_mut() {
*first = events;
}
true
}
#[cfg(not(feature = "gpu"))]
fn simulate_interval_on_gpu(&mut self, _t_previous: i64, _span: i64, _steps: usize) -> bool {
false
}
fn collect_interval(&mut self) -> EventStream {
self.events.clear();
self.events.reserve(self.blocks.iter().map(Vec::len).sum());
for block in &self.blocks {
self.events.extend_from_slice(block);
}
let key = |event: &SimEvent| (event.t, event.y, event.x, event.positive);
if self.events.len() >= PARALLEL_SORT_THRESHOLD {
self.events.par_sort_unstable_by_key(key);
} else {
self.events.sort_unstable_by_key(key);
}
let mut builder =
EventStreamBuilder::with_capacity(self.width, self.height, 0.001, self.events.len());
for event in &self.events {
builder.push(event.x, event.y, event.t, event.positive);
}
builder.build()
}
fn advance(&mut self, t_us: i64) {
std::mem::swap(&mut self.prev_log, &mut self.log_now);
std::mem::swap(&mut self.prev_luma, &mut self.luma);
self.t_previous = Some(t_us);
self.frame += 1;
}
fn upsample_steps(&self, span_us: i64) -> usize {
if span_us <= 0 {
return 1;
}
let ceiling = self.config.max_upsample.clamp(1, MAX_UPSAMPLE_CEILING);
match self.config.upsample {
Upsample::Off => 1,
Upsample::Fixed(n) => n.clamp(1, ceiling),
Upsample::Adaptive {
max_events_per_pixel,
} => {
let budget = max_events_per_pixel.max(0.1);
((self.worst_contrast() / budget).ceil() as usize).clamp(1, ceiling)
}
}
}
fn worst_contrast(&self) -> f32 {
let contrast = |(pixel, (before, after)): (&PixelState, (&f32, &f32))| {
(after - before).abs() / pixel.thres_pos
};
let pairs = self.prev_log.iter().zip(&self.log_now);
if self.state.len() < PARALLEL_PIXEL_THRESHOLD {
self.state
.iter()
.zip(pairs)
.map(contrast)
.fold(0.0_f32, f32::max)
} else {
self.state
.par_iter()
.zip(self.prev_log.par_iter().zip(&self.log_now))
.map(contrast)
.reduce(|| 0.0_f32, f32::max)
}
}
fn empty_stream(&self) -> EventStream {
EventStreamBuilder::new(self.width, self.height, 0.001).build()
}
}
fn block_seed(seed: u64, frame: u64, block: usize) -> u64 {
let mut z = seed
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(frame.wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add((block as u64).wrapping_mul(0x94D0_49BB_1331_11EB));
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
struct NoiseTable {
table: [f32; NOISE_LUT_LEN],
enabled: bool,
}
impl NoiseTable {
fn new(rate_hz: f32, dt_s: f32) -> Self {
let enabled = rate_hz > 0.0 && dt_s > 0.0;
let mut table = [0.0_f32; NOISE_LUT_LEN];
if enabled {
for (index, slot) in table.iter_mut().enumerate() {
let luma = index as f32 / (NOISE_LUT_LEN - 1) as f32;
let scale = 1.0 - (1.0 - SHOT_NOISE_BRIGHT_FACTOR) * luma;
let lambda = rate_hz * scale * dt_s / 2.0;
*slot = 1.0 - (-lambda).exp();
}
}
Self { table, enabled }
}
#[inline]
fn probability(&self, luma: f32) -> f32 {
let index = (luma.clamp(0.0, 1.0) * (NOISE_LUT_LEN - 1) as f32) as usize;
self.table[index]
}
}
#[allow(clippy::too_many_arguments)]
#[inline]
fn integrate_pixel(
pixel: &mut PixelState,
x: u16,
y: u16,
target_log: f32,
luma: f32,
t_start: i64,
t_end: i64,
dt_s: f32,
config: &SimulatorConfig,
noise: &NoiseTable,
rng: &mut SmallRng,
events: &mut Vec<SimEvent>,
) {
let filtered = if config.cutoff_hz > 0.0 && dt_s > 0.0 {
let bandwidth = MIN_BANDWIDTH_FRACTION + (1.0 - MIN_BANDWIDTH_FRACTION) * luma;
let tau = 1.0 / (2.0 * std::f32::consts::PI * config.cutoff_hz * bandwidth);
let epsilon = (dt_s / tau).clamp(0.0, 1.0);
pixel.lowpass += epsilon * (target_log - pixel.lowpass);
pixel.lowpass
} else {
pixel.lowpass = target_log;
target_log
};
if config.leak_rate_hz > 0.0 && dt_s > 0.0 {
pixel.log_ref -= pixel.thres_pos * config.leak_rate_hz * dt_s;
}
let delta = filtered - pixel.log_ref;
let positive = delta > 0.0;
let threshold = if positive {
pixel.thres_pos
} else {
pixel.thres_neg
};
let crossings = (delta.abs() / threshold).floor() as i64;
if crossings > 0 {
let span = (t_end - t_start) as f64;
for k in 1..=crossings {
let fraction = (k as f32 * threshold / delta.abs()).clamp(0.0, 1.0) as f64;
let t = t_start + (span * fraction) as i64;
if t - pixel.last_t < config.refractory_us {
continue;
}
pixel.last_t = t;
events.push(SimEvent {
t: t.max(0),
x,
y,
positive,
});
}
let signed = if positive { 1.0 } else { -1.0 };
pixel.log_ref += signed * crossings as f32 * threshold;
}
if noise.enabled {
let probability = noise.probability(luma);
for polarity in [true, false] {
if rng.gen::<f32>() < probability {
let t = t_start + (rng.gen::<f64>() * (t_end - t_start) as f64) as i64;
if t - pixel.last_t >= config.refractory_us {
pixel.last_t = t;
events.push(SimEvent {
t: t.max(0),
x,
y,
positive: polarity,
});
}
}
}
}
}
fn lin_log(intensity_255: f32) -> f32 {
let x = intensity_255.max(0.0);
if x <= LIN_LOG_THRESHOLD {
x * (LIN_LOG_THRESHOLD.ln() / LIN_LOG_THRESHOLD)
} else {
x.ln()
}
}
fn srgb_to_linear() -> &'static [f32; 256] {
static TABLE: OnceLock<[f32; 256]> = OnceLock::new();
TABLE.get_or_init(|| {
std::array::from_fn(|level| {
let v = level as f32 / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
})
})
}
pub fn linear_luma(r: u8, g: u8, b: u8) -> f32 {
let table = srgb_to_linear();
0.2126 * table[usize::from(r)] + 0.7152 * table[usize::from(g)] + 0.0722 * table[usize::from(b)]
}
pub fn luma_from_rgb(image: &Rgb8Image) -> Vec<f32> {
let convert = |pixel: &[u8; 3]| linear_luma(pixel[0], pixel[1], pixel[2]);
let (pixels, _) = image.pixels.as_chunks::<3>();
if pixels.len() < PARALLEL_PIXEL_THRESHOLD {
return pixels.iter().map(convert).collect();
}
pixels.par_iter().map(convert).collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SimulateProgress {
pub frames: usize,
pub total_frames: Option<usize>,
pub events: usize,
}
pub fn simulate_video(
path: &std::path::Path,
config: SimulatorConfig,
scale: Option<(usize, usize)>,
max_frames: Option<usize>,
on_events: impl FnMut(EventStream) -> std::io::Result<()>,
) -> std::io::Result<(usize, usize)> {
simulate_video_on(
path,
config,
crate::accel::Device::Cpu,
None,
scale,
max_frames,
on_events,
|_| Ok(()),
)
}
pub fn simulate_video_with_progress(
path: &std::path::Path,
config: SimulatorConfig,
scale: Option<(usize, usize)>,
max_frames: Option<usize>,
on_events: impl FnMut(EventStream) -> std::io::Result<()>,
on_progress: impl FnMut(SimulateProgress) -> std::io::Result<()>,
) -> std::io::Result<(usize, usize)> {
simulate_video_on(
path,
config,
crate::accel::Device::Cpu,
None,
scale,
max_frames,
on_events,
on_progress,
)
}
#[allow(clippy::too_many_arguments)]
pub fn simulate_video_on(
path: &std::path::Path,
config: SimulatorConfig,
device: crate::accel::Device,
mut interpolate: Option<crate::interp::Interpolation<'_>>,
scale: Option<(usize, usize)>,
max_frames: Option<usize>,
mut on_events: impl FnMut(EventStream) -> std::io::Result<()>,
mut on_progress: impl FnMut(SimulateProgress) -> std::io::Result<()>,
) -> std::io::Result<(usize, usize)> {
let mut decoder = crate::video::FfmpegDecoder::open(path, scale)?;
let info = decoder.info();
let total_frames = match (info.frames, max_frames) {
(Some(total), Some(limit)) => Some(total.min(limit)),
(total, limit) => total.or(limit),
};
let mut simulator = Simulator::new(info.width, info.height, config).on_device(device);
let us_per_frame = (1_000_000.0 / info.fps.max(1e-6)).round() as i64;
let (mut frames, mut events) = (0usize, 0usize);
let mut previous: Option<Vec<f32>> = None;
while let Some(image) = decoder.next_frame()? {
if max_frames.is_some_and(|limit| frames >= limit) {
break;
}
let luma = luma_from_rgb(&image);
let t = frames as i64 * us_per_frame;
let mut push = |simulator: &mut Simulator,
frame: &[f32],
t: i64,
events: &mut usize|
-> std::io::Result<()> {
let stream = simulator.push_frame(frame, t);
*events += stream.len();
if !stream.is_empty() {
on_events(stream)?;
}
Ok(())
};
if let (Some(plan), Some(before)) = (interpolate.as_mut(), previous.as_ref()) {
let fractions = plan.fractions();
let between = plan
.interpolator
.between(before, &luma, info.width, info.height, &fractions)
.map_err(|error| std::io::Error::other(error.to_string()))?;
for (fraction, frame) in fractions.iter().zip(&between) {
let at = t - us_per_frame + (f64::from(*fraction) * us_per_frame as f64) as i64;
push(&mut simulator, frame, at, &mut events)?;
}
}
push(&mut simulator, &luma, t, &mut events)?;
if interpolate.is_some() {
previous = Some(luma);
}
frames += 1;
on_progress(SimulateProgress {
frames,
total_frames,
events,
})?;
}
Ok((frames, events))
}
#[cfg(test)]
mod tests {
use super::*;
fn constant(width: usize, height: usize, value: f32) -> Vec<f32> {
vec![value; width * height]
}
#[test]
fn lin_log_is_continuous_at_the_join() {
let below = lin_log(LIN_LOG_THRESHOLD - 0.001);
let at = lin_log(LIN_LOG_THRESHOLD);
let above = lin_log(LIN_LOG_THRESHOLD + 0.001);
assert!((below - at).abs() < 1e-3, "{below} vs {at}");
assert!((above - at).abs() < 1e-3, "{above} vs {at}");
assert!(lin_log(0.0).is_finite());
}
#[test]
fn linear_luma_matches_srgb_endpoints() {
assert!((linear_luma(0, 0, 0) - 0.0).abs() < 1e-6);
assert!((linear_luma(255, 255, 255) - 1.0).abs() < 1e-4);
assert!((linear_luma(128, 128, 128) - 0.216).abs() < 0.01);
}
#[test]
fn the_first_frame_only_seeds_state() {
let mut sim = Simulator::new(4, 4, SimulatorConfig::ideal());
assert!(sim.push_frame(&constant(4, 4, 0.5), 0).is_empty());
}
#[test]
fn a_static_scene_emits_nothing_when_ideal() {
let mut sim = Simulator::new(8, 8, SimulatorConfig::ideal());
sim.push_frame(&constant(8, 8, 0.5), 0);
for step in 1..5 {
assert!(sim.push_frame(&constant(8, 8, 0.5), step * 1000).is_empty());
}
}
#[test]
fn event_count_matches_the_analytic_prediction() {
let config = SimulatorConfig {
pos_thres: 0.2,
..SimulatorConfig::ideal()
};
let (before, after) = (0.2_f32, 0.8_f32);
let expected =
((lin_log(after * 255.0) - lin_log(before * 255.0)).abs() / 0.2).floor() as usize;
assert!(
expected > 1,
"test is only meaningful for multiple crossings"
);
let mut sim = Simulator::new(4, 4, config);
sim.push_frame(&constant(4, 4, before), 0);
let events = sim.push_frame(&constant(4, 4, after), 10_000);
assert_eq!(events.len(), expected * 16);
assert!(
events.ps().iter().all(|&p| p),
"a brightening emits ON only"
);
}
#[test]
fn timestamps_are_interpolated_across_the_interval() {
let mut sim = Simulator::new(
1,
1,
SimulatorConfig {
pos_thres: 0.1,
..SimulatorConfig::ideal()
},
);
sim.push_frame(&constant(1, 1, 0.1), 0);
let events = sim.push_frame(&constant(1, 1, 0.9), 10_000);
assert!(events.len() > 2);
let ts = events.ts();
assert!(ts.windows(2).all(|w| w[0] <= w[1]), "must be ascending");
assert!(
ts.first() != ts.last(),
"all timestamps identical — interpolation is not happening"
);
assert!(ts.iter().all(|&t| (0..=10_000).contains(&t)));
}
#[test]
fn output_is_globally_sorted_across_pixels() {
let mut sim = Simulator::new(16, 16, SimulatorConfig::default());
sim.push_frame(&constant(16, 16, 0.2), 0);
for step in 1..6 {
let brightness = 0.2 + 0.1 * step as f32;
let events = sim.push_frame(&constant(16, 16, brightness), step * 10_000);
assert!(events.ts().windows(2).all(|w| w[0] <= w[1]));
}
}
#[test]
fn timestamps_are_never_negative() {
let mut sim = Simulator::new(4, 4, SimulatorConfig::default());
sim.push_frame(&constant(4, 4, 0.5), 0);
let events = sim.push_frame(&constant(4, 4, 0.9), 5_000);
assert!(events.ts().iter().all(|&t| t >= 0));
}
#[test]
fn same_seed_gives_identical_output() {
let run = || {
let mut sim = Simulator::new(8, 8, SimulatorConfig::default());
sim.push_frame(&constant(8, 8, 0.3), 0);
sim.push_frame(&constant(8, 8, 0.6), 20_000)
};
let (first, second) = (run(), run());
assert_eq!(first.ts(), second.ts());
assert_eq!(first.xs(), second.xs());
}
const PARALLEL_SIDE: usize = 288;
const _: () = assert!(
PARALLEL_SIDE * PARALLEL_SIDE >= PARALLEL_PIXEL_THRESHOLD,
"the test sensor must be large enough to take the parallel path"
);
fn run_parallel_sensor() -> EventStream {
let mut sim = Simulator::new(
PARALLEL_SIDE,
PARALLEL_SIDE,
SimulatorConfig {
seed: 12345,
..SimulatorConfig::default()
},
);
let mut last = sim.empty_stream();
for step in 0..4 {
let brightness = 0.2 + 0.15 * step as f32;
last = sim.push_frame(
&constant(PARALLEL_SIDE, PARALLEL_SIDE, brightness),
step as i64 * 20_000,
);
}
last
}
#[test]
fn output_does_not_depend_on_the_thread_count() {
let in_pool = |threads: usize| {
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("building a rayon pool")
.install(run_parallel_sensor)
};
let (one, many) = (in_pool(1), in_pool(8));
assert_eq!(one.len(), many.len(), "event counts differ");
assert_eq!(one.ts(), many.ts());
assert_eq!(one.xs(), many.xs());
assert_eq!(one.ys(), many.ys());
assert_eq!(one.ps(), many.ps());
}
#[test]
fn parallel_output_is_sorted_and_in_bounds() {
let events = run_parallel_sensor();
assert!(!events.is_empty(), "a brightening sensor must emit");
assert!(events.ts().windows(2).all(|w| w[0] <= w[1]));
assert!(events
.xs()
.iter()
.all(|&x| usize::from(x) < PARALLEL_SIDE));
assert!(events
.ys()
.iter()
.all(|&y| usize::from(y) < PARALLEL_SIDE));
}
#[test]
fn every_pixel_block_is_reached() {
let (width, height) = (PARALLEL_SIDE, PARALLEL_SIDE);
let config = SimulatorConfig {
pos_thres: 0.2,
..SimulatorConfig::ideal()
};
let mut sim = Simulator::new(width, height, config);
sim.push_frame(&constant(width, height, 0.2), 0);
let events = sim.push_frame(&constant(width, height, 0.8), 10_000);
let mut seen = vec![0usize; width * height];
for index in 0..events.len() {
seen[usize::from(events.ys()[index]) * width + usize::from(events.xs()[index])] += 1;
}
let expected = seen[0];
assert!(expected > 0, "an ideal ramp must fire every pixel");
assert!(
seen.iter().all(|&count| count == expected),
"every pixel must fire the same number of times on a uniform ramp"
);
}
#[test]
fn max_upsample_caps_the_subdivision() {
let steps_for = |max_upsample: usize| {
let mut sim = Simulator::new(
4,
4,
SimulatorConfig {
pos_thres: 0.05,
max_upsample,
upsample: Upsample::default(),
..SimulatorConfig::ideal()
},
);
sim.prev_log.fill(lin_log(0.1 * 255.0));
sim.log_now.fill(lin_log(0.9 * 255.0));
sim.upsample_steps(10_000)
};
let uncapped = steps_for(MAX_UPSAMPLE);
assert!(
uncapped > 10,
"this contrast should ask for a real subdivision, got {uncapped}"
);
assert_eq!(steps_for(1), 1, "a ceiling of 1 disables subdivision");
assert_eq!(steps_for(10), 10, "the ceiling binds below what is asked");
assert_eq!(
steps_for(MAX_UPSAMPLE_CEILING),
uncapped,
"raising the ceiling past the demand changes nothing"
);
}
#[test]
fn leak_alone_fires_at_about_its_rate() {
let config = SimulatorConfig {
leak_rate_hz: 10.0,
shot_noise_rate_hz: 0.0,
sigma_thres: 0.0,
cutoff_hz: 0.0,
refractory_us: 0,
upsample: Upsample::Off,
..SimulatorConfig::default()
};
let (width, height) = (8, 8);
let mut sim = Simulator::new(width, height, config);
sim.push_frame(&constant(width, height, 0.5), 0);
let mut total = 0;
for step in 1..=10 {
let events = sim.push_frame(&constant(width, height, 0.5), step * 100_000);
assert!(events.ps().iter().all(|&p| p), "leak emits ON events");
total += events.len();
}
let expected = 10.0 * (width * height) as f64;
let ratio = total as f64 / expected;
assert!(
ratio > 0.5 && ratio < 1.5,
"leak produced {total}, expected ~{expected}"
);
}
#[test]
fn shot_noise_alone_scales_with_its_rate() {
let noisy = |rate: f32| {
let config = SimulatorConfig {
shot_noise_rate_hz: rate,
leak_rate_hz: 0.0,
cutoff_hz: 0.0,
upsample: Upsample::Off,
..SimulatorConfig::default()
};
let mut sim = Simulator::new(16, 16, config);
sim.push_frame(&constant(16, 16, 0.5), 0);
(1..=10)
.map(|step| sim.push_frame(&constant(16, 16, 0.5), step * 100_000).len())
.sum::<usize>()
};
assert_eq!(noisy(0.0), 0);
let (low, high) = (noisy(5.0), noisy(50.0));
assert!(low > 0, "some noise expected at 5 Hz");
assert!(
high > low * 3,
"10x the rate should give far more events: {low} vs {high}"
);
}
#[test]
fn threshold_mismatch_desynchronises_pixels() {
let spread = |sigma: f32| {
let config = SimulatorConfig {
sigma_thres: sigma,
leak_rate_hz: 0.0,
shot_noise_rate_hz: 0.0,
cutoff_hz: 0.0,
refractory_us: 0,
upsample: Upsample::Off,
..SimulatorConfig::default()
};
let pixels = 16 * 16;
let mut sim = Simulator::new(16, 16, config);
sim.push_frame(&constant(16, 16, 0.3), 0);
let events = sim.push_frame(&constant(16, 16, 0.45), 10_000);
let unique: std::collections::HashSet<i64> = events.ts().iter().copied().collect();
(events.len() / pixels, unique.len())
};
let (per_pixel, unique) = spread(0.0);
assert!(per_pixel > 0);
assert_eq!(
unique, per_pixel,
"identical thresholds must put every pixel on the same {per_pixel} timestamps"
);
let (_, spread_unique) = spread(0.05);
assert!(
spread_unique > unique,
"mismatch must spread the firing times: {spread_unique} vs {unique}"
);
}
#[test]
fn adaptive_upsampling_subdivides_high_contrast_pairs() {
let steps_for = |upsample: Upsample, before: f32, after: f32| {
let config = SimulatorConfig {
pos_thres: 0.1,
upsample,
..SimulatorConfig::ideal()
};
let mut sim = Simulator::new(4, 4, config);
sim.push_frame(&constant(4, 4, before), 0);
let events = sim.push_frame(&constant(4, 4, after), 10_000);
let unique: std::collections::HashSet<i64> = events.ts().iter().copied().collect();
unique.len()
};
let off = steps_for(Upsample::Off, 0.1, 0.9);
let adaptive = steps_for(
Upsample::Adaptive {
max_events_per_pixel: 1.0,
},
0.1,
0.9,
);
assert!(off > 1 && adaptive > 1);
assert!(
adaptive >= off,
"subdividing must not coarsen timing: {adaptive} vs {off}"
);
}
#[test]
fn simulate_video_streams_a_real_clip() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
return;
}
let mut path = std::env::temp_dir();
path.push(format!("eventcv-sim-{}.mp4", std::process::id()));
let made = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y"])
.args(["-f", "lavfi", "-i", "testsrc=size=64x48:rate=30:duration=1"])
.args(["-pix_fmt", "yuv420p"])
.arg(&path)
.status();
if !matches!(made, Ok(status) if status.success()) {
return;
}
let mut last = -1_i64;
let mut intervals = 0;
let (frames, events) =
simulate_video(&path, SimulatorConfig::default(), None, None, |stream| {
for &t in stream.ts() {
assert!(t >= last, "events must not go backwards across intervals");
last = t;
}
intervals += 1;
Ok(())
})
.expect("simulation should succeed");
assert_eq!(frames, 30, "one second at 30 fps");
assert!(events > 0, "a moving test pattern must generate events");
assert!(
intervals > 1,
"events should arrive across several intervals"
);
std::fs::remove_file(&path).ok();
}
#[test]
fn a_darkening_scene_emits_off_events() {
let mut sim = Simulator::new(4, 4, SimulatorConfig::ideal());
sim.push_frame(&constant(4, 4, 0.9), 0);
let events = sim.push_frame(&constant(4, 4, 0.2), 10_000);
assert!(!events.is_empty());
assert!(
events.ps().iter().all(|&p| !p),
"a darkening emits OFF only"
);
}
#[test]
fn the_refractory_period_thins_a_burst() {
let config = |refractory_us| SimulatorConfig {
pos_thres: 0.05,
refractory_us,
..SimulatorConfig::ideal()
};
let count = |refractory_us| {
let mut sim = Simulator::new(1, 1, config(refractory_us));
sim.push_frame(&constant(1, 1, 0.1), 0);
sim.push_frame(&constant(1, 1, 0.9), 10_000).len()
};
let free = count(0);
let limited = count(4_000);
assert!(
free > limited,
"refractory must suppress: {free} vs {limited}"
);
assert!(limited > 0, "but not suppress everything");
}
}
#[cfg(all(test, feature = "gpu"))]
mod gpu_tests {
use super::{Simulator, SimulatorConfig, Upsample};
use crate::accel::Device;
use crate::EventStream;
fn skip_without_gpu() -> bool {
if crate::accel::gpu_available() {
return false;
}
assert!(
std::env::var("EVENTCV_REQUIRE_GPU").is_err(),
"EVENTCV_REQUIRE_GPU is set but no adapter was found"
);
true
}
fn frames(width: usize, height: usize, count: usize) -> Vec<Vec<f32>> {
(0..count)
.map(|index| {
let edge = (index * width) / count.max(1);
(0..width * height)
.map(|pixel| if pixel % width < edge { 0.85 } else { 0.15 })
.collect()
})
.collect()
}
fn run(config: SimulatorConfig, device: Device) -> Vec<EventStream> {
let (width, height) = (64, 48);
let mut simulator = Simulator::new(width, height, config).on_device(device);
frames(width, height, 12)
.iter()
.enumerate()
.map(|(index, frame)| simulator.push_frame(frame, index as i64 * 10_000))
.collect()
}
fn columns(streams: &[EventStream]) -> (Vec<u16>, Vec<u16>, Vec<i64>, Vec<bool>) {
let mut out = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
for stream in streams {
out.0.extend_from_slice(stream.xs());
out.1.extend_from_slice(stream.ys());
out.2.extend_from_slice(stream.ts());
out.3.extend_from_slice(stream.ps());
}
out
}
#[test]
fn a_noiseless_sensor_produces_the_same_events_on_both_backends() {
if skip_without_gpu() {
return;
}
for upsample in [Upsample::Off, Upsample::Fixed(4)] {
let config = SimulatorConfig {
upsample,
..SimulatorConfig::ideal()
};
let cpu = columns(&run(config, Device::Cpu));
let gpu = columns(&run(config, Device::Gpu));
assert_eq!(cpu.2, gpu.2, "{upsample:?}: timestamps");
assert_eq!(cpu.0, gpu.0, "{upsample:?}: x");
assert_eq!(cpu.1, gpu.1, "{upsample:?}: y");
assert_eq!(cpu.3, gpu.3, "{upsample:?}: polarity");
}
}
#[test]
fn threshold_mismatch_is_the_same_silicon_on_both_backends() {
if skip_without_gpu() {
return;
}
let config = SimulatorConfig {
sigma_thres: 0.05,
seed: 7,
..SimulatorConfig::ideal()
};
let (cpu_x, cpu_y, cpu_t, cpu_p) = columns(&run(config, Device::Cpu));
let (gpu_x, gpu_y, gpu_t, gpu_p) = columns(&run(config, Device::Gpu));
assert_eq!(cpu_t.len(), gpu_t.len(), "event count");
assert_eq!(cpu_x, gpu_x, "x");
assert_eq!(cpu_y, gpu_y, "y");
assert_eq!(cpu_p, gpu_p, "polarity");
let apart: Vec<i64> = cpu_t
.iter()
.zip(&gpu_t)
.map(|(cpu, gpu)| (cpu - gpu).abs())
.filter(|difference| *difference > 0)
.collect();
assert!(
apart.iter().all(|difference| *difference <= 1),
"timestamps should differ by at most a microsecond, got {:?}",
apart.iter().max()
);
assert!(
apart.len() * 100 < cpu_t.len(),
"{} of {} timestamps differ; that is rounding turning into divergence",
apart.len(),
cpu_t.len()
);
}
#[test]
fn shot_noise_agrees_in_rate_rather_than_event_for_event() {
if skip_without_gpu() {
return;
}
let config = SimulatorConfig {
shot_noise_rate_hz: 500.0,
upsample: Upsample::Off,
..SimulatorConfig::ideal()
};
let cpu: usize = run(config, Device::Cpu).iter().map(EventStream::len).sum();
let gpu: usize = run(config, Device::Gpu).iter().map(EventStream::len).sum();
let ratio = gpu as f64 / cpu as f64;
assert!(
(0.9..1.1).contains(&ratio),
"noise rates should agree to ~10%, got {cpu} vs {gpu} ({ratio:.3})"
);
}
#[test]
fn a_noisy_run_is_reproducible_on_the_gpu() {
if skip_without_gpu() {
return;
}
let config = SimulatorConfig {
shot_noise_rate_hz: 500.0,
leak_rate_hz: 5.0,
..SimulatorConfig::default()
};
let first = columns(&run(config, Device::Gpu));
for _ in 0..2 {
assert_eq!(first.2, columns(&run(config, Device::Gpu)).2);
}
}
}
#[cfg(test)]
mod interp_tests {
use super::{simulate_video_on, SimulatorConfig, Upsample};
use crate::interp::{Interpolation, LinearInterpolator};
fn clip() -> Option<std::path::PathBuf> {
std::process::Command::new("ffmpeg").arg("-version").output().ok()?;
let path = std::env::temp_dir().join(format!("eventcv_interp_{}.mp4", std::process::id()));
let made = std::process::Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y"])
.args(["-f", "lavfi", "-i", "testsrc=size=64x48:rate=30:duration=1"])
.args(["-pix_fmt", "yuv420p"])
.arg(&path)
.status()
.ok()?;
made.success().then_some(path)
}
fn count(path: &std::path::Path, factor: usize) -> usize {
let mut linear = LinearInterpolator;
let plan = (factor > 1).then_some(Interpolation {
interpolator: &mut linear as &mut dyn crate::interp::FrameInterpolator,
factor,
});
let mut events = 0;
simulate_video_on(
path,
SimulatorConfig {
upsample: Upsample::Off,
..SimulatorConfig::ideal()
},
crate::accel::Device::Cpu,
plan,
None,
None,
|stream| {
events += stream.len();
Ok(())
},
|_| Ok(()),
)
.expect("simulating the clip");
events
}
#[test]
fn a_linear_interpolator_reproduces_the_uninterpolated_run() {
let Some(path) = clip() else {
return; };
let plain = count(&path, 1);
assert!(plain > 0, "the clip should produce events at all");
for factor in [2, 4] {
let interpolated = count(&path, factor);
let drift = (interpolated as f64 - plain as f64).abs() / plain as f64;
assert!(
drift < 0.02,
"factor {factor}: {plain} events became {interpolated}, which is a change in the \
model rather than in the timing"
);
}
std::fs::remove_file(&path).ok();
}
}