Skip to main content

eventcv_core/
simulate.rs

1//! Frames → events: a DVS pixel simulator.
2//!
3//! Turns a sequence of intensity frames into an [`EventStream`], modelling the pixel closely enough
4//! that the result is usable as training data rather than only as a demo. The model follows Hu et
5//! al., *v2e: From Video Frames to Realistic DVS Events* (CVPRW 2021): log-intensity differencing
6//! with per-pixel threshold mismatch, a finite photoreceptor bandwidth, shot noise, leak events and
7//! a refractory period.
8//!
9//! # What is modelled, and why each part matters
10//!
11//! - **sRGB linearisation, then a lin-log map.** Video is gamma-encoded; taking `ln` of the stored
12//!   8-bit value measures contrast in the wrong space. Below ~20/255 the map is linear rather than
13//!   logarithmic, because `ln` of a near-zero value amplifies quantisation noise into events that a
14//!   real sensor would never emit.
15//! - **Per-pixel threshold mismatch.** Real thresholds vary pixel to pixel by a few percent. Fixed
16//!   thresholds make every pixel on an edge fire in lockstep, which is the most visible way
17//!   synthetic events look synthetic.
18//! - **Photoreceptor bandwidth.** A first-order lowpass whose cutoff falls with intensity, so dark
19//!   scenes lag — the dominant artefact in low-light DVS recordings.
20//! - **Shot noise and leak events.** Background activity that does not come from motion at all. A
21//!   model trained on noiseless synthetic data has never seen it.
22//! - **Interpolated timestamps.** When a pixel crosses its threshold several times between two
23//!   frames, the crossings are spread across the interval by when they actually occurred, rather
24//!   than all being stamped with the frame time. Timing *is* the signal in event data; collapsing it
25//!   to the frame rate discards the temporal precision that motivates using an event camera.
26//!
27//! # What is not modelled
28//!
29//! Motion blur and exposure time in the source footage (they arrive already baked in), arbiter and
30//! bus-bandwidth saturation, hot and dead pixels (see [`EventStream::pixel_dropout`] for those), and
31//! threshold dependence on illumination beyond the bandwidth term.
32
33use 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
45/// Intensity below which the log map is replaced by a linear one, in 8-bit levels.
46///
47/// v2e's value. Below this the sensor is photon-starved and `ln` would turn one-level quantisation
48/// steps into large contrast changes.
49const LIN_LOG_THRESHOLD: f32 = 20.0;
50
51/// Floor on the photoreceptor bandwidth as a fraction of its maximum, so a black pixel still
52/// tracks rather than freezing entirely.
53const MIN_BANDWIDTH_FRACTION: f32 = 0.1;
54
55/// How much quieter shot noise is in the brightest pixels than the darkest (v2e's `c`).
56const SHOT_NOISE_BRIGHT_FACTOR: f32 = 0.25;
57
58/// Default ceiling on adaptive upsampling, so a hard cut between two frames cannot ask for
59/// thousands of sub-steps and stall the run. Overridable per run via
60/// [`SimulatorConfig::max_upsample`] — the ceiling is a cost/accuracy trade, not a physical bound.
61pub const MAX_UPSAMPLE: usize = 64;
62
63/// Hard ceiling on [`SimulatorConfig::max_upsample`]. A sub-step costs a full pass over every
64/// pixel, so an unbounded value turns a typo into an unkillable run.
65const MAX_UPSAMPLE_CEILING: usize = 4096;
66
67/// Pixels per parallel work block.
68///
69/// Fixed rather than derived from the core count, because each block seeds its own noise RNG: a
70/// block size that varied with the machine would make the events depend on how many cores ran the
71/// simulation. Sized so one block's [`PixelState`] (~192 KiB) stays resident across every sub-step
72/// of a frame pair, which is why the sub-step loop lives *inside* the block rather than around it.
73const PIXEL_BLOCK: usize = 8192;
74
75/// Below this many pixels the rayon dispatch costs more than the work it splits, so the whole
76/// interval runs inline. Mirrors `cmax`'s `PARALLEL_EVENT_THRESHOLD`.
77const PARALLEL_PIXEL_THRESHOLD: usize = 1 << 16;
78
79/// Above this many events in one interval the sort is worth handing to rayon.
80const PARALLEL_SORT_THRESHOLD: usize = 1 << 16;
81
82/// Entries in the per-sub-interval shot-noise probability table, indexed by quantised luma.
83/// One 8-bit level of luma moves the probability by well under a percent of itself, which is far
84/// below the noise the table is describing.
85const NOISE_LUT_LEN: usize = 256;
86
87/// How finely the interval between two source frames is subdivided before simulating.
88///
89/// Between two frames the true intensity path is unknown and is assumed linear. That assumption
90/// degrades as more happens between them, so subdividing and interpolating recovers timing accuracy
91/// — the axis no other event-vision library covers today.
92#[derive(Clone, Copy, Debug, PartialEq)]
93pub enum Upsample {
94    /// Simulate straight from the source frames.
95    Off,
96    /// Always insert `n - 1` interpolated frames between each pair.
97    Fixed(usize),
98    /// Subdivide until no pixel would emit more than `max_events_per_pixel` events per sub-interval.
99    ///
100    /// v2e picks its factor from optical flow, keeping motion under a pixel per sub-interval. This
101    /// uses contrast instead: the quantity that actually bounds timestamp error is how many
102    /// threshold crossings are being packed into one linear interpolation, and that is measured
103    /// directly rather than inferred from displacement — no flow estimate required.
104    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/// Pixel-model parameters. Defaults follow v2e's for a typical DVS.
116#[derive(Clone, Copy, Debug, PartialEq)]
117pub struct SimulatorConfig {
118    /// Log-intensity increase that emits an ON event.
119    pub pos_thres: f32,
120    /// Log-intensity decrease that emits an OFF event.
121    pub neg_thres: f32,
122    /// Standard deviation of the per-pixel Gaussian threshold mismatch, in log units.
123    pub sigma_thres: f32,
124    /// Minimum time between two events from the same pixel, in µs.
125    pub refractory_us: i64,
126    /// Photoreceptor bandwidth for a white pixel, in Hz. `0` disables the lowpass.
127    pub cutoff_hz: f32,
128    /// Spontaneous ON events per pixel per second. `0` disables leak.
129    pub leak_rate_hz: f32,
130    /// Shot-noise events per pixel per second in the darkest pixels. `0` disables noise.
131    pub shot_noise_rate_hz: f32,
132    /// Seed for mismatch and noise, so a run is reproducible from its configuration.
133    pub seed: u64,
134    /// Frame subdivision — see [`Upsample`].
135    pub upsample: Upsample,
136    /// Ceiling on the sub-steps any one frame pair may be split into.
137    ///
138    /// Adaptive upsampling is driven by the *busiest* pixel, so one high-contrast edge can push a
139    /// whole 1080p frame to the ceiling — 64 full-sensor passes for that pair. Lowering this
140    /// bounds the worst case at some cost in timestamp accuracy where the scene really is that
141    /// fast; raising it buys accuracy on hard cuts. Clamped to `1..=4096`.
142    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    /// A noiseless, ideal sensor: fixed thresholds, no mismatch, bandwidth, leak or shot noise.
164    ///
165    /// Not realistic, and not the default for that reason — but it is what makes the simulator
166    /// *testable*, since an ideal pixel's event count is an analytic function of the contrast.
167    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/// Everything the model remembers about one pixel.
181///
182/// Held as one array of these rather than five parallel arrays: the sub-step loop touches all of a
183/// pixel's state together (so this is one cache line's worth of locality instead of five streams),
184/// and a single slice is what [`par_chunks_mut`](rayon::slice::ParallelSliceMut::par_chunks_mut)
185/// can hand to a worker without a five-way zip.
186#[derive(Clone, Copy, Debug)]
187pub(crate) struct PixelState {
188    /// Memorised log intensity — the level each event is measured against.
189    pub(crate) log_ref: f32,
190    /// Photoreceptor lowpass state.
191    pub(crate) lowpass: f32,
192    /// Thresholds, drawn once at construction: mismatch is a property of the silicon, not noise
193    /// that resamples every frame.
194    pub(crate) thres_pos: f32,
195    pub(crate) thres_neg: f32,
196    /// Last emission time, for the refractory check.
197    pub(crate) last_t: i64,
198}
199
200/// One generated event, before it is sorted and handed to an [`EventStreamBuilder`].
201#[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
209/// A DVS pixel array, driven one frame at a time.
210///
211/// Frames are pushed in time order and each call returns the events generated since the previous
212/// one, already sorted. Nothing accumulates across calls, so a recording of any length simulates in
213/// memory proportional to one frame interval rather than to the whole output.
214///
215/// # Parallelism
216///
217/// A frame pair is split into fixed-size blocks of pixels and simulated with rayon. Pixels are
218/// independent — nothing in the model couples one to its neighbours — so the only shared thing was
219/// the random number generator, and each block draws from its own stream instead (see
220/// [`block_seed`]). Both the block size and the seed derivation are fixed constants, so the output
221/// is a function of the configuration alone: the same `seed` gives the same events on one core or
222/// thirty-two.
223pub struct Simulator {
224    config: SimulatorConfig,
225    width: usize,
226    height: usize,
227    /// Per-pixel model state.
228    state: Vec<PixelState>,
229    /// Working buffer for the incoming frame's lin-log intensity, reused across calls.
230    log_now: Vec<f32>,
231    /// Luma in `[0, 1]` for the incoming frame, reused across calls.
232    luma: Vec<f32>,
233    /// The previous frame's pair, swapped with the incoming buffers rather than cloned.
234    prev_log: Vec<f32>,
235    prev_luma: Vec<f32>,
236    /// `None` until the first frame seeds the state.
237    t_previous: Option<i64>,
238    /// Counts pushed frames, so each interval's noise draws a different stream.
239    frame: u64,
240    /// One event buffer per pixel block, kept allocated across frames so a steady event rate stops
241    /// reallocating. Concatenated in block order, which is what makes the result reproducible.
242    blocks: Vec<Vec<SimEvent>>,
243    /// The concatenated interval, sorted in place before being handed out.
244    events: Vec<SimEvent>,
245    /// Where the pixel model runs. The CPU is the reference and the default; see
246    /// [`on_device`](Self::on_device).
247    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        // Mismatch is sampled once and kept, from one serial stream: it is O(pixels) at
255        // construction rather than per sub-step, so there is nothing to gain by splitting it, and
256        // keeping it serial means the pattern of mismatch is unchanged by the parallel loop.
257        // Thresholds are clamped well above zero: a threshold at or below zero would emit
258        // unboundedly on any change at all.
259        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    /// Runs the pixel model on `device`.
298    ///
299    /// # What is and is not the same as the CPU
300    ///
301    /// The deterministic half is the same by construction. Threshold mismatch is drawn *here*, once
302    /// at [`new`](Self::new), and uploaded — the GPU never regenerates it — so two sensors built
303    /// from one seed are the same silicon whichever backend runs them. The sub-interval boundaries
304    /// are computed on the host in `f64` and uploaded for the same reason. What is left is `exp`,
305    /// `floor` and the arithmetic between them, where WGSL and Rust agree to a few ULP.
306    ///
307    /// The random half is **not** the same sample path. The CPU draws from a generator seeded per
308    /// block of pixels and consumed in order; a kernel with one invocation per pixel cannot replay a
309    /// sequential stream without serialising itself. The GPU uses a counter-based generator keyed on
310    /// `(seed, frame, sub-step, pixel, polarity)`, which is reproducible from run to run and across
311    /// devices — every operation in it is integer — but draws different numbers from the CPU's.
312    ///
313    /// So, measured rather than hoped for (see this module's `gpu_tests`):
314    ///
315    /// | configuration | how the backends compare |
316    /// |---|---|
317    /// | no shot noise, no leak, no mismatch | identical, event for event |
318    /// | mismatch on, still noiseless | same events, same polarity; a couple of timestamps in ~25 000 land a microsecond apart, because the crossing fraction is `f64` on the CPU and WGSL has no `f64` |
319    /// | noise on | same event *rate* to within a few per cent, different events — and bit-reproducible from run to run for a given seed |
320    ///
321    /// A run that needs to be compared against a stored CPU result should therefore either turn the
322    /// random terms off or compare distributions. A run that just needs events does not care.
323    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    /// Feeds one frame, returning every event generated since the previous frame.
333    ///
334    /// `t_us` must not go backwards. The first frame only seeds the pixel state and returns an empty
335    /// stream — there is no interval to integrate over yet.
336    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    /// Simulates one frame pair into `self.blocks`, in parallel over blocks of pixels.
366    ///
367    /// The sub-step loop runs *inside* each block rather than around all of them: pixels never
368    /// interact, so a block can walk the whole interval on its own, which keeps its state hot in
369    /// cache and costs one rayon dispatch per frame instead of one per sub-step.
370    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        // Field-by-field borrows: the state is written, everything else only read.
377        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                // Linear interpolation in log space between the two frames. Sub-intervals are
388                // walked in order, so events stay grouped in ascending time even before the sort.
389                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                // Coordinates are walked rather than divided out per pixel: a block is a
396                // contiguous index range, so this is two divisions per block instead of two per
397                // pixel per sub-step (hundreds of millions of them on a 1080p clip).
398                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    /// The same interval on the GPU, returning `false` when there is no adapter so the caller can
435    /// fall through to the CPU loop.
436    ///
437    /// The kernel writes its events through one atomic cursor, so they arrive in whatever order the
438    /// scheduler produced them. That costs nothing: [`collect_interval`](Self::collect_interval)
439    /// sorts every interval by `(t, y, x, polarity)` anyway — the CPU path needs the same sort to
440    /// stay independent of its core count — so the two backends hand back the same ordering.
441    ///
442    /// The events land in `blocks[0]` because that is what `collect_interval` concatenates; the
443    /// remaining blocks are cleared so a run that switches backends mid-recording cannot replay a
444    /// stale interval.
445    #[cfg(feature = "gpu")]
446    fn simulate_interval_on_gpu(&mut self, t_previous: i64, span: i64, steps: usize) -> bool {
447        // Boundaries in `f64`, matching the CPU loop exactly; the shader has no `f64` to do this in.
448        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    /// Concatenates the per-block buffers, sorts, and builds the interval's stream.
480    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        // Sorting per interval rather than globally is what keeps this streaming: intervals are
488        // produced in time order, so concatenating sorted blocks is already globally sorted, and no
489        // sort ever sees more than one interval's events.
490        //
491        // The key is the whole event, not just `t`. An unstable sort leaves ties in an
492        // implementation-defined order, and a *parallel* unstable sort's order also depends on how
493        // the work was split — so with `t` alone the output would shift with the core count.
494        // Ordering ties by position makes the result total and machine-independent.
495        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    /// Rolls the incoming frame into the "previous" slot. Swapping rather than cloning: the old
511    /// code copied two full-frame `Vec<f32>`s per frame, which is 16 MB of memcpy per 1080p frame.
512    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    /// How many sub-intervals this frame pair needs.
520    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    /// The most events any single pixel would emit over this frame pair.
538    ///
539    /// The busiest pixel decides the subdivision: if it would emit n events over the pair,
540    /// subdivide until no sub-interval asks for more than the budget from it.
541    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
566/// Splits `seed` into an independent RNG stream per (frame, pixel block).
567///
568/// The noise draws happen inside a parallel loop, so they cannot share one generator. What keeps a
569/// run reproducible is that each block's seed is derived from the *configuration* — never from the
570/// thread or the core count — and that [`PIXEL_BLOCK`] is a constant, so the same `seed` always
571/// partitions the sensor the same way. SplitMix64's finaliser does the mixing: cheap, and enough
572/// that neighbouring blocks show no visible correlation in their noise.
573fn 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
583/// The probability that a pixel emits a shot-noise event of one polarity in a sub-interval,
584/// tabulated over luma.
585///
586/// The probability depends on the pixel only through its luma, so evaluating it directly meant one
587/// `exp` per pixel per sub-step — a couple of hundred million of them on a second of 1080p.
588/// Tabulating once per sub-interval turns that into an array lookup.
589struct NoiseTable {
590    table: [f32; NOISE_LUT_LEN],
591    /// False when noise is switched off or the sub-interval has no duration, so the caller skips
592    /// the RNG draws entirely rather than drawing and comparing against zero.
593    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                // `1 - exp(-λ)` is the Poisson probability of at least one event, not `λ` itself.
605                // The distinction only shows up once λ approaches 1 — but there a raw `λ` exceeds
606                // 1, the comparison is always true, and the intensity dependence silently
607                // disappears into saturation. At most one event per polarity per sub-interval is
608                // emitted either way, so a rate high relative to the interval still saturates:
609                // that is a reason to subdivide (see `Upsample`), not something to paper over.
610                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/// One pixel over one sub-interval: bandwidth, threshold crossings, leak and noise.
625///
626/// A free function rather than a method because the parallel loop hands out one `&mut PixelState`
627/// per pixel and one RNG per block; a `&mut self` method could not be called from inside it.
628#[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    // Photoreceptor lowpass: cutoff falls with intensity, floored so dark pixels still track.
645    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    // Leak: the memorised level decays, so a still scene still emits the occasional ON event.
657    // Per-pixel thresholds decorrelate them, which is what stops leak looking like a metronome.
658    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            // The k-th crossing happens when the interpolated level reaches k thresholds away,
675            // which under the linear assumption is a fixed fraction of the way through.
676            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        // Carry the remainder rather than discarding it, so slow ramps still fire eventually.
690        let signed = if positive { 1.0 } else { -1.0 };
691        pixel.log_ref += signed * crossings as f32 * threshold;
692    }
693
694    // Shot noise: Poisson-thin, quieter where the scene is bright.
695    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
714/// Lin-log intensity map over 8-bit levels: linear below [`LIN_LOG_THRESHOLD`], `ln` above.
715///
716/// The two segments meet continuously — the linear slope is chosen as `ln(threshold)/threshold`
717/// precisely so there is no discontinuity to emit a spurious event.
718fn 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
727/// The sRGB electro-optical transfer function over all 256 8-bit levels.
728///
729/// [`linear_luma`] runs for every pixel of every frame, and the EOTF costs a `powf` per channel —
730/// three per pixel, a quarter of a billion for a couple of seconds of 1080p, and by some margin the
731/// most expensive thing in the decode path. The input is 8-bit, so the whole function fits in a
732/// 1 KiB table and the conversion becomes three loads.
733fn 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
747/// Rec. 601 luma of an sRGB pixel, linearised, in `[0, 1]`.
748///
749/// Linearising first matters: contrast is a ratio of *light*, and sRGB stores a gamma-encoded
750/// value. Measuring log contrast on the encoded value bakes the display transfer function into
751/// every threshold.
752pub 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
757/// Converts a decoded RGB frame into the linear luma the simulator consumes.
758pub 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    // `par_iter` is indexed, so collecting preserves pixel order.
765    pixels.par_iter().map(convert).collect()
766}
767
768/// How far a [`simulate_video_with_progress`] run has got.
769#[derive(Clone, Copy, Debug, PartialEq, Eq)]
770pub struct SimulateProgress {
771    /// Frames pushed so far.
772    pub frames: usize,
773    /// Frames the source is expected to hold, when `ffprobe` could say — `None` for a stream whose
774    /// length it does not report.
775    pub total_frames: Option<usize>,
776    /// Events emitted so far.
777    pub events: usize,
778}
779
780/// Simulates a whole video file, calling `on_events` with each interval's events as they are
781/// produced.
782///
783/// Streaming by construction: nothing is held but the current frame pair, so a long recording costs
784/// memory proportional to one interval rather than to the whole output. `scale` decodes at a
785/// different resolution, which is much cheaper than decoding full-size and downsampling after.
786/// `max_frames` stops early.
787pub 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
806/// [`simulate_video`], reporting after every frame.
807///
808/// Split out rather than folded in because a simulation is long enough that a caller needs to see
809/// it moving, and there is nothing else in the loop that knows the frame count. `on_progress`
810/// returns a `Result` so that the same hook can stop the run — a caller polling for `Ctrl+C` has
811/// nowhere else to do it, since a whole simulation is one uninterruptible call otherwise.
812pub 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/// [`simulate_video_with_progress`] on a chosen device, optionally interpolating first.
833///
834/// See [`Simulator::on_device`] for exactly how the two backends differ, and
835/// [`crate::interp`] for what `interpolate` does and why it happens here rather than inside the
836/// pixel model.
837#[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    // Frame index drives the clock rather than any wall time: the source's own frame rate is what
856    // the timestamps have to be consistent with.
857    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    // The previous source frame, kept only when there is something to interpolate between.
860    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        // Interpolated frames are pushed as ordinary source frames at proportional timestamps, so
881        // the simulator sees a denser recording and nothing else changes.
882        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        // The linear segment must not blow up at zero the way ln would.
923        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        // Mid-grey in sRGB is ~0.216 in linear light, not 0.5 — this is the whole point of
931        // linearising, and a regression here silently changes every threshold.
932        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        // An ideal pixel emits floor(|Δ log I| / threshold) events. With a known step, that is
953        // arithmetic — the check evlib's simulator does not make.
954        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        // The property evlib gets wrong: several crossings at one pixel must be spread through the
979        // interval, not all stamped with the frame time.
980        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        // Per-interval sorting is what makes this true; a regression would show up as unsorted
1003        // output the moment more than one pixel fires.
1004        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        // `EventStream::iter` casts to u64, so a negative timestamp silently becomes enormous and
1016        // corrupts every time-surface representation downstream.
1017        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    /// A sensor big enough to cross [`PARALLEL_PIXEL_THRESHOLD`], so the tests below exercise the
1036    /// rayon path rather than the serial fallback.
1037    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        // The whole reason the noise RNG is split per pixel block rather than shared: a run has to
1066        // be reproducible from its seed, and "reproducible" has to survive being run on a different
1067        // machine with a different number of cores. If this fails, `block_seed` or `PIXEL_BLOCK`
1068        // has picked up a dependency on the rayon pool.
1069        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        // A coordinate walked incrementally across a block (rather than divided out per pixel) is
1102        // easy to get subtly wrong at a block boundary, and the symptom would be a whole band of
1103        // the sensor silently never firing. An ideal ramp fires every pixel exactly the same
1104        // number of times, so the coordinates must cover the grid exactly.
1105        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        // The knob exists because adaptive upsampling is driven by the busiest pixel, so a single
1129        // hard edge can cost the ceiling in full-sensor passes. Asserted on the step count itself
1130        // rather than on the events, because a *linear* ramp interpolates to the same timestamps
1131        // however finely it is subdivided — the cost is real even where the output barely moves.
1132        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        // The contrast here asks for ~44 sub-steps, so the ceiling is what binds below that.
1148        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        // Thresholds unreachable by the (static) scene, so every event must come from leak.
1165        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        // One second in 100 ms steps.
1179        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        // With identical thresholds a uniform ramp fires every pixel in lockstep; with mismatch the
1220        // firing times spread. That spread is the point of modelling mismatch at all.
1221        // Measured as distinct timestamps *per pixel's worth of events*: a pixel crossing its
1222        // threshold n times legitimately produces n distinct times even in lockstep, so the
1223        // signal of mismatch is that pixels stop agreeing with each other.
1224        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 per pixel, and how many distinct times those landed on overall.
1240            (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        // Off: every crossing is placed by interpolation within the single interval.
1270        let off = steps_for(Upsample::Off, 0.1, 0.9);
1271        // Adaptive: the pair is subdivided first, so crossings land on a finer time grid.
1272        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        // End to end through ffmpeg: synthesise a moving pattern, simulate it, and check events
1289        // arrive in ascending time across interval boundaries — the property per-interval sorting
1290        // is supposed to guarantee globally.
1291        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/// The GPU pixel model against the CPU one — the contract [`Simulator::on_device`] states.
1367#[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    /// A moving edge: the frames that make a DVS actually fire, rather than a flat field that
1385    /// exercises nothing but the leak term.
1386    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    /// The regression gate. With the random terms off, the only difference left between the
1419    /// backends is `exp`/`floor` rounding — and on a threshold model that either changes nothing or
1420    /// changes an event, so "the same events" is the right assertion, not "close".
1421    #[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    /// Mismatch is drawn on the host and uploaded, so a sensor built from one seed is the same
1441    /// silicon on either backend: the same pixels fire, the same number of times, with the same
1442    /// polarity.
1443    ///
1444    /// Timestamps are where the two can part, and only just. The moment of the k-th crossing is
1445    /// `k * threshold / |delta|` of the way through the sub-interval — a fraction the CPU widens to
1446    /// `f64` before scaling, and the GPU cannot, because WGSL has no `f64`. On a run of ~25 000
1447    /// events that puts a couple of them one microsecond apart. The assertion is that bound, not a
1448    /// hopeful tolerance: anything larger would mean the model itself had diverged.
1449    #[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    /// With noise on the two backends draw different numbers by design, so the claim is
1486    /// distributional: the same scene should produce a comparable event rate, not the same events.
1487    #[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    /// The counter-based generator's whole point: same seed, same events, every run.
1507    #[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/// Interpolation as a *preprocessing* stage — that it changes what the simulator sees, and that
1525/// leaving it out changes nothing.
1526#[cfg(test)]
1527mod interp_tests {
1528    use super::{simulate_video_on, SimulatorConfig, Upsample};
1529    use crate::interp::{Interpolation, LinearInterpolator};
1530
1531    /// A short synthetic clip, or `None` when ffmpeg is not on `PATH` — the same shape the other
1532    /// video-backed test in this module uses.
1533    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    /// The linear baseline is what the simulator's own subdivision already does, so inserting
1574    /// frames that way must not change the events — which is the strongest statement available that
1575    /// the plumbing is a *preprocessing* stage and not a change to the model.
1576    #[test]
1577    fn a_linear_interpolator_reproduces_the_uninterpolated_run() {
1578        let Some(path) = clip() else {
1579            return; // no ffmpeg here
1580        };
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            // Not exactly equal: the extra frames give the threshold model more chances to cross,
1586            // so timestamps sharpen. The count is what must not move.
1587            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}