1use std::sync::OnceLock;
34
35use rand::{
36 rngs::{SmallRng, StdRng},
37 Rng, SeedableRng,
38};
39use rand_distr::{Distribution, Normal};
40use rayon::prelude::*;
41
42use crate::viz::Rgb8Image;
43use crate::{EventStream, EventStreamBuilder};
44
45const LIN_LOG_THRESHOLD: f32 = 20.0;
50
51const MIN_BANDWIDTH_FRACTION: f32 = 0.1;
54
55const SHOT_NOISE_BRIGHT_FACTOR: f32 = 0.25;
57
58pub const MAX_UPSAMPLE: usize = 64;
62
63const MAX_UPSAMPLE_CEILING: usize = 4096;
66
67const PIXEL_BLOCK: usize = 8192;
74
75const PARALLEL_PIXEL_THRESHOLD: usize = 1 << 16;
78
79const PARALLEL_SORT_THRESHOLD: usize = 1 << 16;
81
82const NOISE_LUT_LEN: usize = 256;
86
87#[derive(Clone, Copy, Debug, PartialEq)]
93pub enum Upsample {
94 Off,
96 Fixed(usize),
98 Adaptive { max_events_per_pixel: f32 },
105}
106
107impl Default for Upsample {
108 fn default() -> Self {
109 Self::Adaptive {
110 max_events_per_pixel: 1.0,
111 }
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq)]
117pub struct SimulatorConfig {
118 pub pos_thres: f32,
120 pub neg_thres: f32,
122 pub sigma_thres: f32,
124 pub refractory_us: i64,
126 pub cutoff_hz: f32,
128 pub leak_rate_hz: f32,
130 pub shot_noise_rate_hz: f32,
132 pub seed: u64,
134 pub upsample: Upsample,
136 pub max_upsample: usize,
143}
144
145impl Default for SimulatorConfig {
146 fn default() -> Self {
147 Self {
148 pos_thres: 0.2,
149 neg_thres: 0.2,
150 sigma_thres: 0.03,
151 refractory_us: 100,
152 cutoff_hz: 200.0,
153 leak_rate_hz: 1.0,
154 shot_noise_rate_hz: 10.0,
155 seed: 0,
156 upsample: Upsample::default(),
157 max_upsample: MAX_UPSAMPLE,
158 }
159 }
160}
161
162impl SimulatorConfig {
163 pub fn ideal() -> Self {
168 Self {
169 sigma_thres: 0.0,
170 cutoff_hz: 0.0,
171 leak_rate_hz: 0.0,
172 shot_noise_rate_hz: 0.0,
173 refractory_us: 0,
174 upsample: Upsample::Off,
175 ..Self::default()
176 }
177 }
178}
179
180#[derive(Clone, Copy, Debug)]
187pub(crate) struct PixelState {
188 pub(crate) log_ref: f32,
190 pub(crate) lowpass: f32,
192 pub(crate) thres_pos: f32,
195 pub(crate) thres_neg: f32,
196 pub(crate) last_t: i64,
198}
199
200#[derive(Clone, Copy, Debug)]
202pub(crate) struct SimEvent {
203 pub(crate) t: i64,
204 pub(crate) x: u16,
205 pub(crate) y: u16,
206 pub(crate) positive: bool,
207}
208
209pub struct Simulator {
224 config: SimulatorConfig,
225 width: usize,
226 height: usize,
227 state: Vec<PixelState>,
229 log_now: Vec<f32>,
231 luma: Vec<f32>,
233 prev_log: Vec<f32>,
235 prev_luma: Vec<f32>,
236 t_previous: Option<i64>,
238 frame: u64,
240 blocks: Vec<Vec<SimEvent>>,
243 events: Vec<SimEvent>,
245 device: crate::accel::Device,
248}
249
250impl Simulator {
251 pub fn new(width: usize, height: usize, config: SimulatorConfig) -> Self {
252 let pixels = width * height;
253 let mut rng = StdRng::seed_from_u64(config.seed);
254 let sample = |rng: &mut StdRng, base: f32| -> Vec<f32> {
260 match Normal::new(0.0_f32, config.sigma_thres.max(0.0)) {
261 Ok(normal) if config.sigma_thres > 0.0 => (0..pixels)
262 .map(|_| (base + normal.sample(rng)).max(base * 0.1).max(1e-3))
263 .collect(),
264 _ => vec![base.max(1e-3); pixels],
265 }
266 };
267 let thres_pos = sample(&mut rng, config.pos_thres);
268 let thres_neg = sample(&mut rng, config.neg_thres);
269 let state = thres_pos
270 .into_iter()
271 .zip(thres_neg)
272 .map(|(thres_pos, thres_neg)| PixelState {
273 log_ref: 0.0,
274 lowpass: 0.0,
275 thres_pos,
276 thres_neg,
277 last_t: i64::MIN / 4,
278 })
279 .collect();
280 Self {
281 config,
282 width,
283 height,
284 state,
285 log_now: vec![0.0; pixels],
286 luma: vec![0.0; pixels],
287 prev_log: vec![0.0; pixels],
288 prev_luma: vec![0.0; pixels],
289 t_previous: None,
290 frame: 0,
291 blocks: vec![Vec::new(); pixels.div_ceil(PIXEL_BLOCK)],
292 events: Vec::new(),
293 device: crate::accel::Device::Cpu,
294 }
295 }
296
297 pub fn on_device(mut self, device: crate::accel::Device) -> Self {
324 self.device = device;
325 self
326 }
327
328 pub fn sensor_size(&self) -> (usize, usize) {
329 (self.width, self.height)
330 }
331
332 pub fn push_frame(&mut self, frame: &[f32], t_us: i64) -> EventStream {
337 assert_eq!(
338 frame.len(),
339 self.width * self.height,
340 "frame does not match the simulator's sensor size"
341 );
342 for (index, &value) in frame.iter().enumerate() {
343 let clamped = value.clamp(0.0, 1.0);
344 self.luma[index] = clamped;
345 self.log_now[index] = lin_log(clamped * 255.0);
346 }
347
348 let Some(t_previous) = self.t_previous else {
349 for (pixel, &log) in self.state.iter_mut().zip(&self.log_now) {
350 pixel.log_ref = log;
351 pixel.lowpass = log;
352 pixel.last_t = t_us;
353 }
354 self.advance(t_us);
355 return self.empty_stream();
356 };
357
358 let span = (t_us - t_previous).max(0);
359 let steps = self.upsample_steps(span);
360 self.simulate_interval(t_previous, span, steps);
361 self.advance(t_us);
362 self.collect_interval()
363 }
364
365 fn simulate_interval(&mut self, t_previous: i64, span: i64, steps: usize) {
371 if self.device == crate::accel::Device::Gpu && self.simulate_interval_on_gpu(t_previous, span, steps) {
372 return;
373 }
374 let width = self.width;
375 let pixels = self.state.len();
376 let config = &self.config;
378 let frame = self.frame;
379 let (prev_log, log_now) = (&self.prev_log, &self.log_now);
380 let (prev_luma, luma) = (&self.prev_luma, &self.luma);
381
382 let run = |block: usize, state: &mut [PixelState], out: &mut Vec<SimEvent>| {
383 out.clear();
384 let base = block * PIXEL_BLOCK;
385 let mut rng = SmallRng::seed_from_u64(block_seed(config.seed, frame, block));
386 for step in 1..=steps {
387 let alpha = step as f32 / steps as f32;
390 let t_start = t_previous + (span as f64 * (step - 1) as f64 / steps as f64) as i64;
391 let t_end = t_previous + (span as f64 * step as f64 / steps as f64) as i64;
392 let dt_s = ((t_end - t_start) as f64 / 1e6) as f32;
393 let noise = NoiseTable::new(config.shot_noise_rate_hz, dt_s);
394
395 let (mut x, mut y) = ((base % width) as u16, (base / width) as u16);
399 for (offset, pixel) in state.iter_mut().enumerate() {
400 let index = base + offset;
401 let target = prev_log[index] + (log_now[index] - prev_log[index]) * alpha;
402 let pixel_luma = prev_luma[index] + (luma[index] - prev_luma[index]) * alpha;
403 integrate_pixel(
404 pixel, x, y, target, pixel_luma, t_start, t_end, dt_s, config, &noise,
405 &mut rng, out,
406 );
407 x += 1;
408 if usize::from(x) == width {
409 x = 0;
410 y += 1;
411 }
412 }
413 }
414 };
415
416 if pixels < PARALLEL_PIXEL_THRESHOLD {
417 for (block, (state, out)) in self
418 .state
419 .chunks_mut(PIXEL_BLOCK)
420 .zip(self.blocks.iter_mut())
421 .enumerate()
422 {
423 run(block, state, out);
424 }
425 } else {
426 self.state
427 .par_chunks_mut(PIXEL_BLOCK)
428 .zip(self.blocks.par_iter_mut())
429 .enumerate()
430 .for_each(|(block, (state, out))| run(block, state, out));
431 }
432 }
433
434 #[cfg(feature = "gpu")]
446 fn simulate_interval_on_gpu(&mut self, t_previous: i64, span: i64, steps: usize) -> bool {
447 let bounds: Vec<i64> = (0..=steps)
449 .map(|step| t_previous + (span as f64 * step as f64 / steps as f64) as i64)
450 .collect();
451 let Some(events) = crate::accel::sim::run_interval(
452 self.width,
453 self.height,
454 &self.config,
455 self.frame,
456 &bounds,
457 &self.prev_log,
458 &self.log_now,
459 &self.prev_luma,
460 &self.luma,
461 &mut self.state,
462 ) else {
463 return false;
464 };
465 for block in &mut self.blocks {
466 block.clear();
467 }
468 if let Some(first) = self.blocks.first_mut() {
469 *first = events;
470 }
471 true
472 }
473
474 #[cfg(not(feature = "gpu"))]
475 fn simulate_interval_on_gpu(&mut self, _t_previous: i64, _span: i64, _steps: usize) -> bool {
476 false
477 }
478
479 fn collect_interval(&mut self) -> EventStream {
481 self.events.clear();
482 self.events.reserve(self.blocks.iter().map(Vec::len).sum());
483 for block in &self.blocks {
484 self.events.extend_from_slice(block);
485 }
486
487 let key = |event: &SimEvent| (event.t, event.y, event.x, event.positive);
496 if self.events.len() >= PARALLEL_SORT_THRESHOLD {
497 self.events.par_sort_unstable_by_key(key);
498 } else {
499 self.events.sort_unstable_by_key(key);
500 }
501
502 let mut builder =
503 EventStreamBuilder::with_capacity(self.width, self.height, 0.001, self.events.len());
504 for event in &self.events {
505 builder.push(event.x, event.y, event.t, event.positive);
506 }
507 builder.build()
508 }
509
510 fn advance(&mut self, t_us: i64) {
513 std::mem::swap(&mut self.prev_log, &mut self.log_now);
514 std::mem::swap(&mut self.prev_luma, &mut self.luma);
515 self.t_previous = Some(t_us);
516 self.frame += 1;
517 }
518
519 fn upsample_steps(&self, span_us: i64) -> usize {
521 if span_us <= 0 {
522 return 1;
523 }
524 let ceiling = self.config.max_upsample.clamp(1, MAX_UPSAMPLE_CEILING);
525 match self.config.upsample {
526 Upsample::Off => 1,
527 Upsample::Fixed(n) => n.clamp(1, ceiling),
528 Upsample::Adaptive {
529 max_events_per_pixel,
530 } => {
531 let budget = max_events_per_pixel.max(0.1);
532 ((self.worst_contrast() / budget).ceil() as usize).clamp(1, ceiling)
533 }
534 }
535 }
536
537 fn worst_contrast(&self) -> f32 {
542 let contrast = |(pixel, (before, after)): (&PixelState, (&f32, &f32))| {
543 (after - before).abs() / pixel.thres_pos
544 };
545 let pairs = self.prev_log.iter().zip(&self.log_now);
546 if self.state.len() < PARALLEL_PIXEL_THRESHOLD {
547 self.state
548 .iter()
549 .zip(pairs)
550 .map(contrast)
551 .fold(0.0_f32, f32::max)
552 } else {
553 self.state
554 .par_iter()
555 .zip(self.prev_log.par_iter().zip(&self.log_now))
556 .map(contrast)
557 .reduce(|| 0.0_f32, f32::max)
558 }
559 }
560
561 fn empty_stream(&self) -> EventStream {
562 EventStreamBuilder::new(self.width, self.height, 0.001).build()
563 }
564}
565
566fn block_seed(seed: u64, frame: u64, block: usize) -> u64 {
574 let mut z = seed
575 .wrapping_mul(0x9E37_79B9_7F4A_7C15)
576 .wrapping_add(frame.wrapping_mul(0xBF58_476D_1CE4_E5B9))
577 .wrapping_add((block as u64).wrapping_mul(0x94D0_49BB_1331_11EB));
578 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
579 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
580 z ^ (z >> 31)
581}
582
583struct NoiseTable {
590 table: [f32; NOISE_LUT_LEN],
591 enabled: bool,
594}
595
596impl NoiseTable {
597 fn new(rate_hz: f32, dt_s: f32) -> Self {
598 let enabled = rate_hz > 0.0 && dt_s > 0.0;
599 let mut table = [0.0_f32; NOISE_LUT_LEN];
600 if enabled {
601 for (index, slot) in table.iter_mut().enumerate() {
602 let luma = index as f32 / (NOISE_LUT_LEN - 1) as f32;
603 let scale = 1.0 - (1.0 - SHOT_NOISE_BRIGHT_FACTOR) * luma;
604 let lambda = rate_hz * scale * dt_s / 2.0;
611 *slot = 1.0 - (-lambda).exp();
612 }
613 }
614 Self { table, enabled }
615 }
616
617 #[inline]
618 fn probability(&self, luma: f32) -> f32 {
619 let index = (luma.clamp(0.0, 1.0) * (NOISE_LUT_LEN - 1) as f32) as usize;
620 self.table[index]
621 }
622}
623
624#[allow(clippy::too_many_arguments)]
629#[inline]
630fn integrate_pixel(
631 pixel: &mut PixelState,
632 x: u16,
633 y: u16,
634 target_log: f32,
635 luma: f32,
636 t_start: i64,
637 t_end: i64,
638 dt_s: f32,
639 config: &SimulatorConfig,
640 noise: &NoiseTable,
641 rng: &mut SmallRng,
642 events: &mut Vec<SimEvent>,
643) {
644 let filtered = if config.cutoff_hz > 0.0 && dt_s > 0.0 {
646 let bandwidth = MIN_BANDWIDTH_FRACTION + (1.0 - MIN_BANDWIDTH_FRACTION) * luma;
647 let tau = 1.0 / (2.0 * std::f32::consts::PI * config.cutoff_hz * bandwidth);
648 let epsilon = (dt_s / tau).clamp(0.0, 1.0);
649 pixel.lowpass += epsilon * (target_log - pixel.lowpass);
650 pixel.lowpass
651 } else {
652 pixel.lowpass = target_log;
653 target_log
654 };
655
656 if config.leak_rate_hz > 0.0 && dt_s > 0.0 {
659 pixel.log_ref -= pixel.thres_pos * config.leak_rate_hz * dt_s;
660 }
661
662 let delta = filtered - pixel.log_ref;
663 let positive = delta > 0.0;
664 let threshold = if positive {
665 pixel.thres_pos
666 } else {
667 pixel.thres_neg
668 };
669 let crossings = (delta.abs() / threshold).floor() as i64;
670
671 if crossings > 0 {
672 let span = (t_end - t_start) as f64;
673 for k in 1..=crossings {
674 let fraction = (k as f32 * threshold / delta.abs()).clamp(0.0, 1.0) as f64;
677 let t = t_start + (span * fraction) as i64;
678 if t - pixel.last_t < config.refractory_us {
679 continue;
680 }
681 pixel.last_t = t;
682 events.push(SimEvent {
683 t: t.max(0),
684 x,
685 y,
686 positive,
687 });
688 }
689 let signed = if positive { 1.0 } else { -1.0 };
691 pixel.log_ref += signed * crossings as f32 * threshold;
692 }
693
694 if noise.enabled {
696 let probability = noise.probability(luma);
697 for polarity in [true, false] {
698 if rng.gen::<f32>() < probability {
699 let t = t_start + (rng.gen::<f64>() * (t_end - t_start) as f64) as i64;
700 if t - pixel.last_t >= config.refractory_us {
701 pixel.last_t = t;
702 events.push(SimEvent {
703 t: t.max(0),
704 x,
705 y,
706 positive: polarity,
707 });
708 }
709 }
710 }
711 }
712}
713
714fn lin_log(intensity_255: f32) -> f32 {
719 let x = intensity_255.max(0.0);
720 if x <= LIN_LOG_THRESHOLD {
721 x * (LIN_LOG_THRESHOLD.ln() / LIN_LOG_THRESHOLD)
722 } else {
723 x.ln()
724 }
725}
726
727fn srgb_to_linear() -> &'static [f32; 256] {
734 static TABLE: OnceLock<[f32; 256]> = OnceLock::new();
735 TABLE.get_or_init(|| {
736 std::array::from_fn(|level| {
737 let v = level as f32 / 255.0;
738 if v <= 0.04045 {
739 v / 12.92
740 } else {
741 ((v + 0.055) / 1.055).powf(2.4)
742 }
743 })
744 })
745}
746
747pub fn linear_luma(r: u8, g: u8, b: u8) -> f32 {
753 let table = srgb_to_linear();
754 0.2126 * table[usize::from(r)] + 0.7152 * table[usize::from(g)] + 0.0722 * table[usize::from(b)]
755}
756
757pub fn luma_from_rgb(image: &Rgb8Image) -> Vec<f32> {
759 let convert = |pixel: &[u8; 3]| linear_luma(pixel[0], pixel[1], pixel[2]);
760 let (pixels, _) = image.pixels.as_chunks::<3>();
761 if pixels.len() < PARALLEL_PIXEL_THRESHOLD {
762 return pixels.iter().map(convert).collect();
763 }
764 pixels.par_iter().map(convert).collect()
766}
767
768#[derive(Clone, Copy, Debug, PartialEq, Eq)]
770pub struct SimulateProgress {
771 pub frames: usize,
773 pub total_frames: Option<usize>,
776 pub events: usize,
778}
779
780pub fn simulate_video(
788 path: &std::path::Path,
789 config: SimulatorConfig,
790 scale: Option<(usize, usize)>,
791 max_frames: Option<usize>,
792 on_events: impl FnMut(EventStream) -> std::io::Result<()>,
793) -> std::io::Result<(usize, usize)> {
794 simulate_video_on(
795 path,
796 config,
797 crate::accel::Device::Cpu,
798 None,
799 scale,
800 max_frames,
801 on_events,
802 |_| Ok(()),
803 )
804}
805
806pub fn simulate_video_with_progress(
813 path: &std::path::Path,
814 config: SimulatorConfig,
815 scale: Option<(usize, usize)>,
816 max_frames: Option<usize>,
817 on_events: impl FnMut(EventStream) -> std::io::Result<()>,
818 on_progress: impl FnMut(SimulateProgress) -> std::io::Result<()>,
819) -> std::io::Result<(usize, usize)> {
820 simulate_video_on(
821 path,
822 config,
823 crate::accel::Device::Cpu,
824 None,
825 scale,
826 max_frames,
827 on_events,
828 on_progress,
829 )
830}
831
832#[allow(clippy::too_many_arguments)]
838pub fn simulate_video_on(
839 path: &std::path::Path,
840 config: SimulatorConfig,
841 device: crate::accel::Device,
842 mut interpolate: Option<crate::interp::Interpolation<'_>>,
843 scale: Option<(usize, usize)>,
844 max_frames: Option<usize>,
845 mut on_events: impl FnMut(EventStream) -> std::io::Result<()>,
846 mut on_progress: impl FnMut(SimulateProgress) -> std::io::Result<()>,
847) -> std::io::Result<(usize, usize)> {
848 let mut decoder = crate::video::FfmpegDecoder::open(path, scale)?;
849 let info = decoder.info();
850 let total_frames = match (info.frames, max_frames) {
851 (Some(total), Some(limit)) => Some(total.min(limit)),
852 (total, limit) => total.or(limit),
853 };
854 let mut simulator = Simulator::new(info.width, info.height, config).on_device(device);
855 let us_per_frame = (1_000_000.0 / info.fps.max(1e-6)).round() as i64;
858 let (mut frames, mut events) = (0usize, 0usize);
859 let mut previous: Option<Vec<f32>> = None;
861 while let Some(image) = decoder.next_frame()? {
862 if max_frames.is_some_and(|limit| frames >= limit) {
863 break;
864 }
865 let luma = luma_from_rgb(&image);
866 let t = frames as i64 * us_per_frame;
867 let mut push = |simulator: &mut Simulator,
868 frame: &[f32],
869 t: i64,
870 events: &mut usize|
871 -> std::io::Result<()> {
872 let stream = simulator.push_frame(frame, t);
873 *events += stream.len();
874 if !stream.is_empty() {
875 on_events(stream)?;
876 }
877 Ok(())
878 };
879
880 if let (Some(plan), Some(before)) = (interpolate.as_mut(), previous.as_ref()) {
883 let fractions = plan.fractions();
884 let between = plan
885 .interpolator
886 .between(before, &luma, info.width, info.height, &fractions)
887 .map_err(|error| std::io::Error::other(error.to_string()))?;
888 for (fraction, frame) in fractions.iter().zip(&between) {
889 let at = t - us_per_frame + (f64::from(*fraction) * us_per_frame as f64) as i64;
890 push(&mut simulator, frame, at, &mut events)?;
891 }
892 }
893 push(&mut simulator, &luma, t, &mut events)?;
894 if interpolate.is_some() {
895 previous = Some(luma);
896 }
897 frames += 1;
898 on_progress(SimulateProgress {
899 frames,
900 total_frames,
901 events,
902 })?;
903 }
904 Ok((frames, events))
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910
911 fn constant(width: usize, height: usize, value: f32) -> Vec<f32> {
912 vec![value; width * height]
913 }
914
915 #[test]
916 fn lin_log_is_continuous_at_the_join() {
917 let below = lin_log(LIN_LOG_THRESHOLD - 0.001);
918 let at = lin_log(LIN_LOG_THRESHOLD);
919 let above = lin_log(LIN_LOG_THRESHOLD + 0.001);
920 assert!((below - at).abs() < 1e-3, "{below} vs {at}");
921 assert!((above - at).abs() < 1e-3, "{above} vs {at}");
922 assert!(lin_log(0.0).is_finite());
924 }
925
926 #[test]
927 fn linear_luma_matches_srgb_endpoints() {
928 assert!((linear_luma(0, 0, 0) - 0.0).abs() < 1e-6);
929 assert!((linear_luma(255, 255, 255) - 1.0).abs() < 1e-4);
930 assert!((linear_luma(128, 128, 128) - 0.216).abs() < 0.01);
933 }
934
935 #[test]
936 fn the_first_frame_only_seeds_state() {
937 let mut sim = Simulator::new(4, 4, SimulatorConfig::ideal());
938 assert!(sim.push_frame(&constant(4, 4, 0.5), 0).is_empty());
939 }
940
941 #[test]
942 fn a_static_scene_emits_nothing_when_ideal() {
943 let mut sim = Simulator::new(8, 8, SimulatorConfig::ideal());
944 sim.push_frame(&constant(8, 8, 0.5), 0);
945 for step in 1..5 {
946 assert!(sim.push_frame(&constant(8, 8, 0.5), step * 1000).is_empty());
947 }
948 }
949
950 #[test]
951 fn event_count_matches_the_analytic_prediction() {
952 let config = SimulatorConfig {
955 pos_thres: 0.2,
956 ..SimulatorConfig::ideal()
957 };
958 let (before, after) = (0.2_f32, 0.8_f32);
959 let expected =
960 ((lin_log(after * 255.0) - lin_log(before * 255.0)).abs() / 0.2).floor() as usize;
961 assert!(
962 expected > 1,
963 "test is only meaningful for multiple crossings"
964 );
965
966 let mut sim = Simulator::new(4, 4, config);
967 sim.push_frame(&constant(4, 4, before), 0);
968 let events = sim.push_frame(&constant(4, 4, after), 10_000);
969 assert_eq!(events.len(), expected * 16);
970 assert!(
971 events.ps().iter().all(|&p| p),
972 "a brightening emits ON only"
973 );
974 }
975
976 #[test]
977 fn timestamps_are_interpolated_across_the_interval() {
978 let mut sim = Simulator::new(
981 1,
982 1,
983 SimulatorConfig {
984 pos_thres: 0.1,
985 ..SimulatorConfig::ideal()
986 },
987 );
988 sim.push_frame(&constant(1, 1, 0.1), 0);
989 let events = sim.push_frame(&constant(1, 1, 0.9), 10_000);
990 assert!(events.len() > 2);
991 let ts = events.ts();
992 assert!(ts.windows(2).all(|w| w[0] <= w[1]), "must be ascending");
993 assert!(
994 ts.first() != ts.last(),
995 "all timestamps identical — interpolation is not happening"
996 );
997 assert!(ts.iter().all(|&t| (0..=10_000).contains(&t)));
998 }
999
1000 #[test]
1001 fn output_is_globally_sorted_across_pixels() {
1002 let mut sim = Simulator::new(16, 16, SimulatorConfig::default());
1005 sim.push_frame(&constant(16, 16, 0.2), 0);
1006 for step in 1..6 {
1007 let brightness = 0.2 + 0.1 * step as f32;
1008 let events = sim.push_frame(&constant(16, 16, brightness), step * 10_000);
1009 assert!(events.ts().windows(2).all(|w| w[0] <= w[1]));
1010 }
1011 }
1012
1013 #[test]
1014 fn timestamps_are_never_negative() {
1015 let mut sim = Simulator::new(4, 4, SimulatorConfig::default());
1018 sim.push_frame(&constant(4, 4, 0.5), 0);
1019 let events = sim.push_frame(&constant(4, 4, 0.9), 5_000);
1020 assert!(events.ts().iter().all(|&t| t >= 0));
1021 }
1022
1023 #[test]
1024 fn same_seed_gives_identical_output() {
1025 let run = || {
1026 let mut sim = Simulator::new(8, 8, SimulatorConfig::default());
1027 sim.push_frame(&constant(8, 8, 0.3), 0);
1028 sim.push_frame(&constant(8, 8, 0.6), 20_000)
1029 };
1030 let (first, second) = (run(), run());
1031 assert_eq!(first.ts(), second.ts());
1032 assert_eq!(first.xs(), second.xs());
1033 }
1034
1035 const PARALLEL_SIDE: usize = 288;
1038 const _: () = assert!(
1039 PARALLEL_SIDE * PARALLEL_SIDE >= PARALLEL_PIXEL_THRESHOLD,
1040 "the test sensor must be large enough to take the parallel path"
1041 );
1042
1043 fn run_parallel_sensor() -> EventStream {
1044 let mut sim = Simulator::new(
1045 PARALLEL_SIDE,
1046 PARALLEL_SIDE,
1047 SimulatorConfig {
1048 seed: 12345,
1049 ..SimulatorConfig::default()
1050 },
1051 );
1052 let mut last = sim.empty_stream();
1053 for step in 0..4 {
1054 let brightness = 0.2 + 0.15 * step as f32;
1055 last = sim.push_frame(
1056 &constant(PARALLEL_SIDE, PARALLEL_SIDE, brightness),
1057 step as i64 * 20_000,
1058 );
1059 }
1060 last
1061 }
1062
1063 #[test]
1064 fn output_does_not_depend_on_the_thread_count() {
1065 let in_pool = |threads: usize| {
1070 rayon::ThreadPoolBuilder::new()
1071 .num_threads(threads)
1072 .build()
1073 .expect("building a rayon pool")
1074 .install(run_parallel_sensor)
1075 };
1076 let (one, many) = (in_pool(1), in_pool(8));
1077 assert_eq!(one.len(), many.len(), "event counts differ");
1078 assert_eq!(one.ts(), many.ts());
1079 assert_eq!(one.xs(), many.xs());
1080 assert_eq!(one.ys(), many.ys());
1081 assert_eq!(one.ps(), many.ps());
1082 }
1083
1084 #[test]
1085 fn parallel_output_is_sorted_and_in_bounds() {
1086 let events = run_parallel_sensor();
1087 assert!(!events.is_empty(), "a brightening sensor must emit");
1088 assert!(events.ts().windows(2).all(|w| w[0] <= w[1]));
1089 assert!(events
1090 .xs()
1091 .iter()
1092 .all(|&x| usize::from(x) < PARALLEL_SIDE));
1093 assert!(events
1094 .ys()
1095 .iter()
1096 .all(|&y| usize::from(y) < PARALLEL_SIDE));
1097 }
1098
1099 #[test]
1100 fn every_pixel_block_is_reached() {
1101 let (width, height) = (PARALLEL_SIDE, PARALLEL_SIDE);
1106 let config = SimulatorConfig {
1107 pos_thres: 0.2,
1108 ..SimulatorConfig::ideal()
1109 };
1110 let mut sim = Simulator::new(width, height, config);
1111 sim.push_frame(&constant(width, height, 0.2), 0);
1112 let events = sim.push_frame(&constant(width, height, 0.8), 10_000);
1113
1114 let mut seen = vec![0usize; width * height];
1115 for index in 0..events.len() {
1116 seen[usize::from(events.ys()[index]) * width + usize::from(events.xs()[index])] += 1;
1117 }
1118 let expected = seen[0];
1119 assert!(expected > 0, "an ideal ramp must fire every pixel");
1120 assert!(
1121 seen.iter().all(|&count| count == expected),
1122 "every pixel must fire the same number of times on a uniform ramp"
1123 );
1124 }
1125
1126 #[test]
1127 fn max_upsample_caps_the_subdivision() {
1128 let steps_for = |max_upsample: usize| {
1133 let mut sim = Simulator::new(
1134 4,
1135 4,
1136 SimulatorConfig {
1137 pos_thres: 0.05,
1138 max_upsample,
1139 upsample: Upsample::default(),
1140 ..SimulatorConfig::ideal()
1141 },
1142 );
1143 sim.prev_log.fill(lin_log(0.1 * 255.0));
1144 sim.log_now.fill(lin_log(0.9 * 255.0));
1145 sim.upsample_steps(10_000)
1146 };
1147 let uncapped = steps_for(MAX_UPSAMPLE);
1149 assert!(
1150 uncapped > 10,
1151 "this contrast should ask for a real subdivision, got {uncapped}"
1152 );
1153 assert_eq!(steps_for(1), 1, "a ceiling of 1 disables subdivision");
1154 assert_eq!(steps_for(10), 10, "the ceiling binds below what is asked");
1155 assert_eq!(
1156 steps_for(MAX_UPSAMPLE_CEILING),
1157 uncapped,
1158 "raising the ceiling past the demand changes nothing"
1159 );
1160 }
1161
1162 #[test]
1163 fn leak_alone_fires_at_about_its_rate() {
1164 let config = SimulatorConfig {
1166 leak_rate_hz: 10.0,
1167 shot_noise_rate_hz: 0.0,
1168 sigma_thres: 0.0,
1169 cutoff_hz: 0.0,
1170 refractory_us: 0,
1171 upsample: Upsample::Off,
1172 ..SimulatorConfig::default()
1173 };
1174 let (width, height) = (8, 8);
1175 let mut sim = Simulator::new(width, height, config);
1176 sim.push_frame(&constant(width, height, 0.5), 0);
1177 let mut total = 0;
1178 for step in 1..=10 {
1180 let events = sim.push_frame(&constant(width, height, 0.5), step * 100_000);
1181 assert!(events.ps().iter().all(|&p| p), "leak emits ON events");
1182 total += events.len();
1183 }
1184 let expected = 10.0 * (width * height) as f64;
1185 let ratio = total as f64 / expected;
1186 assert!(
1187 ratio > 0.5 && ratio < 1.5,
1188 "leak produced {total}, expected ~{expected}"
1189 );
1190 }
1191
1192 #[test]
1193 fn shot_noise_alone_scales_with_its_rate() {
1194 let noisy = |rate: f32| {
1195 let config = SimulatorConfig {
1196 shot_noise_rate_hz: rate,
1197 leak_rate_hz: 0.0,
1198 cutoff_hz: 0.0,
1199 upsample: Upsample::Off,
1200 ..SimulatorConfig::default()
1201 };
1202 let mut sim = Simulator::new(16, 16, config);
1203 sim.push_frame(&constant(16, 16, 0.5), 0);
1204 (1..=10)
1205 .map(|step| sim.push_frame(&constant(16, 16, 0.5), step * 100_000).len())
1206 .sum::<usize>()
1207 };
1208 assert_eq!(noisy(0.0), 0);
1209 let (low, high) = (noisy(5.0), noisy(50.0));
1210 assert!(low > 0, "some noise expected at 5 Hz");
1211 assert!(
1212 high > low * 3,
1213 "10x the rate should give far more events: {low} vs {high}"
1214 );
1215 }
1216
1217 #[test]
1218 fn threshold_mismatch_desynchronises_pixels() {
1219 let spread = |sigma: f32| {
1225 let config = SimulatorConfig {
1226 sigma_thres: sigma,
1227 leak_rate_hz: 0.0,
1228 shot_noise_rate_hz: 0.0,
1229 cutoff_hz: 0.0,
1230 refractory_us: 0,
1231 upsample: Upsample::Off,
1232 ..SimulatorConfig::default()
1233 };
1234 let pixels = 16 * 16;
1235 let mut sim = Simulator::new(16, 16, config);
1236 sim.push_frame(&constant(16, 16, 0.3), 0);
1237 let events = sim.push_frame(&constant(16, 16, 0.45), 10_000);
1238 let unique: std::collections::HashSet<i64> = events.ts().iter().copied().collect();
1239 (events.len() / pixels, unique.len())
1241 };
1242 let (per_pixel, unique) = spread(0.0);
1243 assert!(per_pixel > 0);
1244 assert_eq!(
1245 unique, per_pixel,
1246 "identical thresholds must put every pixel on the same {per_pixel} timestamps"
1247 );
1248 let (_, spread_unique) = spread(0.05);
1249 assert!(
1250 spread_unique > unique,
1251 "mismatch must spread the firing times: {spread_unique} vs {unique}"
1252 );
1253 }
1254
1255 #[test]
1256 fn adaptive_upsampling_subdivides_high_contrast_pairs() {
1257 let steps_for = |upsample: Upsample, before: f32, after: f32| {
1258 let config = SimulatorConfig {
1259 pos_thres: 0.1,
1260 upsample,
1261 ..SimulatorConfig::ideal()
1262 };
1263 let mut sim = Simulator::new(4, 4, config);
1264 sim.push_frame(&constant(4, 4, before), 0);
1265 let events = sim.push_frame(&constant(4, 4, after), 10_000);
1266 let unique: std::collections::HashSet<i64> = events.ts().iter().copied().collect();
1267 unique.len()
1268 };
1269 let off = steps_for(Upsample::Off, 0.1, 0.9);
1271 let adaptive = steps_for(
1273 Upsample::Adaptive {
1274 max_events_per_pixel: 1.0,
1275 },
1276 0.1,
1277 0.9,
1278 );
1279 assert!(off > 1 && adaptive > 1);
1280 assert!(
1281 adaptive >= off,
1282 "subdividing must not coarsen timing: {adaptive} vs {off}"
1283 );
1284 }
1285
1286 #[test]
1287 fn simulate_video_streams_a_real_clip() {
1288 if std::process::Command::new("ffmpeg")
1292 .arg("-version")
1293 .output()
1294 .is_err()
1295 {
1296 return;
1297 }
1298 let mut path = std::env::temp_dir();
1299 path.push(format!("eventcv-sim-{}.mp4", std::process::id()));
1300 let made = std::process::Command::new("ffmpeg")
1301 .args(["-hide_banner", "-loglevel", "error", "-y"])
1302 .args(["-f", "lavfi", "-i", "testsrc=size=64x48:rate=30:duration=1"])
1303 .args(["-pix_fmt", "yuv420p"])
1304 .arg(&path)
1305 .status();
1306 if !matches!(made, Ok(status) if status.success()) {
1307 return;
1308 }
1309
1310 let mut last = -1_i64;
1311 let mut intervals = 0;
1312 let (frames, events) =
1313 simulate_video(&path, SimulatorConfig::default(), None, None, |stream| {
1314 for &t in stream.ts() {
1315 assert!(t >= last, "events must not go backwards across intervals");
1316 last = t;
1317 }
1318 intervals += 1;
1319 Ok(())
1320 })
1321 .expect("simulation should succeed");
1322
1323 assert_eq!(frames, 30, "one second at 30 fps");
1324 assert!(events > 0, "a moving test pattern must generate events");
1325 assert!(
1326 intervals > 1,
1327 "events should arrive across several intervals"
1328 );
1329 std::fs::remove_file(&path).ok();
1330 }
1331
1332 #[test]
1333 fn a_darkening_scene_emits_off_events() {
1334 let mut sim = Simulator::new(4, 4, SimulatorConfig::ideal());
1335 sim.push_frame(&constant(4, 4, 0.9), 0);
1336 let events = sim.push_frame(&constant(4, 4, 0.2), 10_000);
1337 assert!(!events.is_empty());
1338 assert!(
1339 events.ps().iter().all(|&p| !p),
1340 "a darkening emits OFF only"
1341 );
1342 }
1343
1344 #[test]
1345 fn the_refractory_period_thins_a_burst() {
1346 let config = |refractory_us| SimulatorConfig {
1347 pos_thres: 0.05,
1348 refractory_us,
1349 ..SimulatorConfig::ideal()
1350 };
1351 let count = |refractory_us| {
1352 let mut sim = Simulator::new(1, 1, config(refractory_us));
1353 sim.push_frame(&constant(1, 1, 0.1), 0);
1354 sim.push_frame(&constant(1, 1, 0.9), 10_000).len()
1355 };
1356 let free = count(0);
1357 let limited = count(4_000);
1358 assert!(
1359 free > limited,
1360 "refractory must suppress: {free} vs {limited}"
1361 );
1362 assert!(limited > 0, "but not suppress everything");
1363 }
1364}
1365
1366#[cfg(all(test, feature = "gpu"))]
1368mod gpu_tests {
1369 use super::{Simulator, SimulatorConfig, Upsample};
1370 use crate::accel::Device;
1371 use crate::EventStream;
1372
1373 fn skip_without_gpu() -> bool {
1374 if crate::accel::gpu_available() {
1375 return false;
1376 }
1377 assert!(
1378 std::env::var("EVENTCV_REQUIRE_GPU").is_err(),
1379 "EVENTCV_REQUIRE_GPU is set but no adapter was found"
1380 );
1381 true
1382 }
1383
1384 fn frames(width: usize, height: usize, count: usize) -> Vec<Vec<f32>> {
1387 (0..count)
1388 .map(|index| {
1389 let edge = (index * width) / count.max(1);
1390 (0..width * height)
1391 .map(|pixel| if pixel % width < edge { 0.85 } else { 0.15 })
1392 .collect()
1393 })
1394 .collect()
1395 }
1396
1397 fn run(config: SimulatorConfig, device: Device) -> Vec<EventStream> {
1398 let (width, height) = (64, 48);
1399 let mut simulator = Simulator::new(width, height, config).on_device(device);
1400 frames(width, height, 12)
1401 .iter()
1402 .enumerate()
1403 .map(|(index, frame)| simulator.push_frame(frame, index as i64 * 10_000))
1404 .collect()
1405 }
1406
1407 fn columns(streams: &[EventStream]) -> (Vec<u16>, Vec<u16>, Vec<i64>, Vec<bool>) {
1408 let mut out = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
1409 for stream in streams {
1410 out.0.extend_from_slice(stream.xs());
1411 out.1.extend_from_slice(stream.ys());
1412 out.2.extend_from_slice(stream.ts());
1413 out.3.extend_from_slice(stream.ps());
1414 }
1415 out
1416 }
1417
1418 #[test]
1422 fn a_noiseless_sensor_produces_the_same_events_on_both_backends() {
1423 if skip_without_gpu() {
1424 return;
1425 }
1426 for upsample in [Upsample::Off, Upsample::Fixed(4)] {
1427 let config = SimulatorConfig {
1428 upsample,
1429 ..SimulatorConfig::ideal()
1430 };
1431 let cpu = columns(&run(config, Device::Cpu));
1432 let gpu = columns(&run(config, Device::Gpu));
1433 assert_eq!(cpu.2, gpu.2, "{upsample:?}: timestamps");
1434 assert_eq!(cpu.0, gpu.0, "{upsample:?}: x");
1435 assert_eq!(cpu.1, gpu.1, "{upsample:?}: y");
1436 assert_eq!(cpu.3, gpu.3, "{upsample:?}: polarity");
1437 }
1438 }
1439
1440 #[test]
1450 fn threshold_mismatch_is_the_same_silicon_on_both_backends() {
1451 if skip_without_gpu() {
1452 return;
1453 }
1454 let config = SimulatorConfig {
1455 sigma_thres: 0.05,
1456 seed: 7,
1457 ..SimulatorConfig::ideal()
1458 };
1459 let (cpu_x, cpu_y, cpu_t, cpu_p) = columns(&run(config, Device::Cpu));
1460 let (gpu_x, gpu_y, gpu_t, gpu_p) = columns(&run(config, Device::Gpu));
1461 assert_eq!(cpu_t.len(), gpu_t.len(), "event count");
1462 assert_eq!(cpu_x, gpu_x, "x");
1463 assert_eq!(cpu_y, gpu_y, "y");
1464 assert_eq!(cpu_p, gpu_p, "polarity");
1465
1466 let apart: Vec<i64> = cpu_t
1467 .iter()
1468 .zip(&gpu_t)
1469 .map(|(cpu, gpu)| (cpu - gpu).abs())
1470 .filter(|difference| *difference > 0)
1471 .collect();
1472 assert!(
1473 apart.iter().all(|difference| *difference <= 1),
1474 "timestamps should differ by at most a microsecond, got {:?}",
1475 apart.iter().max()
1476 );
1477 assert!(
1478 apart.len() * 100 < cpu_t.len(),
1479 "{} of {} timestamps differ; that is rounding turning into divergence",
1480 apart.len(),
1481 cpu_t.len()
1482 );
1483 }
1484
1485 #[test]
1488 fn shot_noise_agrees_in_rate_rather_than_event_for_event() {
1489 if skip_without_gpu() {
1490 return;
1491 }
1492 let config = SimulatorConfig {
1493 shot_noise_rate_hz: 500.0,
1494 upsample: Upsample::Off,
1495 ..SimulatorConfig::ideal()
1496 };
1497 let cpu: usize = run(config, Device::Cpu).iter().map(EventStream::len).sum();
1498 let gpu: usize = run(config, Device::Gpu).iter().map(EventStream::len).sum();
1499 let ratio = gpu as f64 / cpu as f64;
1500 assert!(
1501 (0.9..1.1).contains(&ratio),
1502 "noise rates should agree to ~10%, got {cpu} vs {gpu} ({ratio:.3})"
1503 );
1504 }
1505
1506 #[test]
1508 fn a_noisy_run_is_reproducible_on_the_gpu() {
1509 if skip_without_gpu() {
1510 return;
1511 }
1512 let config = SimulatorConfig {
1513 shot_noise_rate_hz: 500.0,
1514 leak_rate_hz: 5.0,
1515 ..SimulatorConfig::default()
1516 };
1517 let first = columns(&run(config, Device::Gpu));
1518 for _ in 0..2 {
1519 assert_eq!(first.2, columns(&run(config, Device::Gpu)).2);
1520 }
1521 }
1522}
1523
1524#[cfg(test)]
1527mod interp_tests {
1528 use super::{simulate_video_on, SimulatorConfig, Upsample};
1529 use crate::interp::{Interpolation, LinearInterpolator};
1530
1531 fn clip() -> Option<std::path::PathBuf> {
1534 std::process::Command::new("ffmpeg").arg("-version").output().ok()?;
1535 let path = std::env::temp_dir().join(format!("eventcv_interp_{}.mp4", std::process::id()));
1536 let made = std::process::Command::new("ffmpeg")
1537 .args(["-hide_banner", "-loglevel", "error", "-y"])
1538 .args(["-f", "lavfi", "-i", "testsrc=size=64x48:rate=30:duration=1"])
1539 .args(["-pix_fmt", "yuv420p"])
1540 .arg(&path)
1541 .status()
1542 .ok()?;
1543 made.success().then_some(path)
1544 }
1545
1546 fn count(path: &std::path::Path, factor: usize) -> usize {
1547 let mut linear = LinearInterpolator;
1548 let plan = (factor > 1).then_some(Interpolation {
1549 interpolator: &mut linear as &mut dyn crate::interp::FrameInterpolator,
1550 factor,
1551 });
1552 let mut events = 0;
1553 simulate_video_on(
1554 path,
1555 SimulatorConfig {
1556 upsample: Upsample::Off,
1557 ..SimulatorConfig::ideal()
1558 },
1559 crate::accel::Device::Cpu,
1560 plan,
1561 None,
1562 None,
1563 |stream| {
1564 events += stream.len();
1565 Ok(())
1566 },
1567 |_| Ok(()),
1568 )
1569 .expect("simulating the clip");
1570 events
1571 }
1572
1573 #[test]
1577 fn a_linear_interpolator_reproduces_the_uninterpolated_run() {
1578 let Some(path) = clip() else {
1579 return; };
1581 let plain = count(&path, 1);
1582 assert!(plain > 0, "the clip should produce events at all");
1583 for factor in [2, 4] {
1584 let interpolated = count(&path, factor);
1585 let drift = (interpolated as f64 - plain as f64).abs() / plain as f64;
1588 assert!(
1589 drift < 0.02,
1590 "factor {factor}: {plain} events became {interpolated}, which is a change in the \
1591 model rather than in the timing"
1592 );
1593 }
1594 std::fs::remove_file(&path).ok();
1595 }
1596}