Skip to main content

eventcv_core/
cmax.rs

1//! Contrast maximisation — recovering motion by making the warped events sharp.
2//!
3//! Events from a moving camera are smeared along the motion's path. Warp them by a candidate motion
4//! and accumulate them into an *image of warped events* (IWE): guess right and the smear collapses
5//! into sharp edges, guess wrong and it stays blurred. Scoring that sharpness and maximising it
6//! recovers the motion. Gallego et al., *A Unifying Contrast Maximization Framework for Event
7//! Cameras* (CVPR 2018) is the canonical treatment.
8//!
9//! ```no_run
10//! # use eventcv_core::cmax::{CmaxConfig, WarpModel};
11//! # fn demo(stream: &eventcv_core::EventStream) -> Result<(), Box<dyn std::error::Error>> {
12//! let result = stream.contrast_maximise(WarpModel::translation(), CmaxConfig::default())?;
13//! println!("{:?} px/s, {:.1}x sharper", result.params, result.improvement());
14//! let iwe = stream.iwe(WarpModel::Translation, &result.params)?;
15//! # Ok(())
16//! # }
17//! ```
18//!
19//! # Deliberate differences from the reference implementation
20//!
21//! `event_utils` (Stoffregen) is the reference every CMax paper cites. Three things here differ from
22//! it on purpose:
23//!
24//! - **Events are warped to the interval midpoint**, not to the last event's timestamp. Warping to
25//!   the end makes every `dt` negative and gives the earliest events the largest displacement;
26//!   the midpoint halves the worst-case displacement, and with it the linearisation error.
27//! - **Out-of-bounds events are dropped**, not folded onto pixel (0, 0). The reference multiplies
28//!   coordinates by a 0/1 mask, which piles every escaped event into the corner and rewards warps
29//!   that push events off the sensor — a bias directly against what the objective is measuring.
30//! - **The optimiser is derivative-free.** The reference uses BFGS but its own documentation
31//!   recommends numeric gradients as "more stable… less prone to noise", which is an argument for
32//!   not needing gradients at all.
33
34use std::fmt;
35
36use crate::camera::Camera;
37use crate::representation::{EventFrame, EventFrameData};
38use crate::EventStream;
39
40/// Ceiling on the Gaussian blur radius, so a large sigma cannot make one evaluation unbounded.
41const MAX_BLUR_RADIUS: usize = 32;
42
43/// Largest exponent [`Objective::SumOfExponentials`] will evaluate. `exp(32)` is ~8e13, far below
44/// `f64`'s range, and an IWE pixel holding 32 events after blurring is already implausibly dense.
45/// Fixed rather than derived per image so it cannot change the ordering between two images.
46const SOE_MAX_EXPONENT: f64 = 32.0;
47
48/// Event count above which the warp is parallelised. Below this, allocating and reducing a
49/// per-thread image costs more than the scatter it saves. Measured with `cargo bench`, not guessed.
50const PARALLEL_EVENT_THRESHOLD: usize = 20_000;
51
52/// How a candidate motion displaces an event, as a function of how far it is from the reference
53/// time.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub enum WarpModel {
56    /// Constant image-plane velocity, `(vx, vy)` in pixels per second. Two parameters.
57    ///
58    /// The right model for a camera translating parallel to a flat scene, or for one small patch of
59    /// any scene — and the only model the reference implementation has working.
60    Translation,
61    /// Camera rotation, `(wx, wy, wz)` in radians per second, about the optical centre. Three
62    /// parameters, and needs intrinsics to map pixels onto rays.
63    ///
64    /// Rotation is the case where contrast maximisation is at its best: the warp is exact for *any*
65    /// scene regardless of depth, because rotating a camera moves every ray the same way.
66    Rotation { camera: Camera },
67}
68
69impl WarpModel {
70    /// The 2-DoF translation model.
71    pub fn translation() -> Self {
72        Self::Translation
73    }
74
75    /// Number of free parameters.
76    pub fn dimensions(&self) -> usize {
77        match self {
78            Self::Translation => 2,
79            Self::Rotation { .. } => 3,
80        }
81    }
82
83    /// Displaces one event to where it would have been at the reference time.
84    ///
85    /// `dt` is seconds from the reference time — negative for events before it. Returns float
86    /// coordinates; quantising here is what would make the objective staircased and the optimiser
87    /// blind to small improvements.
88    fn warp(&self, x: f64, y: f64, dt: f64, params: &[f64]) -> (f64, f64) {
89        match self {
90            Self::Translation => (x - dt * params[0], y - dt * params[1]),
91            Self::Rotation { camera } => {
92                // Project to a normalised ray, apply the small-angle rotation, project back. Exact
93                // for small `omega * dt`, which is the regime a single slice covers.
94                let nx = (x - camera.cx) / camera.fx;
95                let ny = (y - camera.cy) / camera.fy;
96                let (wx, wy, wz) = (params[0] * dt, params[1] * dt, params[2] * dt);
97                // First-order rotation of the ray (1, nx, ny) about the optical centre.
98                let rx = nx - wz * ny + wy;
99                let ry = ny + wz * nx - wx;
100                (rx * camera.fx + camera.cx, ry * camera.fy + camera.cy)
101            }
102        }
103    }
104}
105
106/// What "sharp" means when scoring an image of warped events. Every objective is *maximised*.
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
108pub enum Objective {
109    /// Variance of the IWE — Gallego & Scaramuzza, RA-L 2017. The standard choice, and the default.
110    #[default]
111    Variance,
112    /// Mean of the squared IWE — Stoffregen & Kleeman, CVPR 2019. Cheaper than variance and
113    /// behaves similarly; slightly more sensitive to the total event count.
114    SumOfSquares,
115    /// Mean of `exp(IWE)` — Stoffregen & Kleeman, CVPR 2019. Rewards concentration much more
116    /// aggressively, which sharpens the optimum but narrows the basin around it.
117    SumOfExponentials,
118}
119
120impl Objective {
121    /// Blur applied to the IWE before scoring, following the reference's per-objective table.
122    ///
123    /// Blur is not cosmetic: without it the objective is a field of isolated spikes with no gradient
124    /// between them, and the optimiser has nothing to follow.
125    pub fn default_blur(self) -> f64 {
126        match self {
127            Self::Variance | Self::SumOfSquares => 1.0,
128            Self::SumOfExponentials => 2.5,
129        }
130    }
131
132    /// Scores an IWE. Higher is sharper.
133    pub fn score(self, iwe: &[f32]) -> f64 {
134        if iwe.is_empty() {
135            return 0.0;
136        }
137        let n = iwe.len() as f64;
138        match self {
139            Self::Variance => {
140                let mean = iwe.iter().map(|&v| f64::from(v)).sum::<f64>() / n;
141                iwe.iter()
142                    .map(|&v| {
143                        let d = f64::from(v) - mean;
144                        d * d
145                    })
146                    .sum::<f64>()
147                    / n
148            }
149            Self::SumOfSquares => {
150                iwe.iter()
151                    .map(|&v| f64::from(v) * f64::from(v))
152                    .sum::<f64>()
153                    / n
154            }
155            Self::SumOfExponentials => {
156                // `exp` is convex, so for a fixed total mass this is maximised by concentrating it —
157                // which is exactly the property being optimised for.
158                //
159                // The exponent is clamped to a *fixed* constant rather than shifted by each image's
160                // own maximum. A per-image shift is not a shared monotonic transform: it rescales
161                // every image to its own peak, which makes a uniform field score higher than a
162                // sharp one and inverts the objective entirely. A fixed clamp is identical across
163                // every image being compared, so it leaves the ordering intact while keeping
164                // `exp` well away from overflow.
165                iwe.iter()
166                    .map(|&v| f64::from(v).min(SOE_MAX_EXPONENT).exp())
167                    .sum::<f64>()
168                    / n
169            }
170        }
171    }
172}
173
174/// Where events are warped to.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
176pub enum TimeReference {
177    /// The middle of the slice. Halves the worst-case displacement compared with either end, which
178    /// halves the error from assuming motion is linear over the interval.
179    #[default]
180    Midpoint,
181    /// The first event's timestamp.
182    Start,
183    /// The last event's timestamp — what the reference implementation uses.
184    End,
185}
186
187/// Optimiser and scoring settings.
188#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct CmaxConfig {
190    pub objective: Objective,
191    pub time_reference: TimeReference,
192    /// Gaussian sigma applied to the IWE before scoring. `None` takes the objective's default.
193    pub blur_sigma: Option<f64>,
194    /// Starting guess, in the model's units. Zero means "assume static and search outwards".
195    pub initial: Option<[f64; 3]>,
196    /// Initial simplex size — how far the first probes reach from `initial`. Too small and the
197    /// optimiser cannot escape a flat region; too large and it steps over the optimum.
198    pub initial_step: f64,
199    pub max_iterations: usize,
200    /// Stop once the simplex spans less than this in parameter units.
201    pub tolerance: f64,
202}
203
204impl Default for CmaxConfig {
205    fn default() -> Self {
206        Self {
207            objective: Objective::default(),
208            time_reference: TimeReference::default(),
209            blur_sigma: None,
210            initial: None,
211            // Pixels per second. A slice of a few tens of ms with motion of a few pixels lands here.
212            initial_step: 50.0,
213            max_iterations: 200,
214            tolerance: 1e-3,
215        }
216    }
217}
218
219/// What the optimiser found.
220#[derive(Clone, Debug, PartialEq)]
221pub struct CmaxResult {
222    /// Recovered motion, in the model's units — px/s for translation, rad/s for rotation.
223    pub params: Vec<f64>,
224    /// Objective value at `params`.
225    pub score: f64,
226    /// Objective value at zero motion, for comparison. A result whose `score` barely exceeds this
227    /// means the optimiser found nothing — there was no coherent motion, or the slice was too short.
228    pub score_at_rest: f64,
229    pub iterations: usize,
230}
231
232impl CmaxResult {
233    /// How much sharper the recovered motion is than assuming the camera was still.
234    ///
235    /// The number to check before trusting `params`: at or below 1.0 the optimiser did not find
236    /// motion, and the parameters are wherever the search happened to stop.
237    pub fn improvement(&self) -> f64 {
238        if self.score_at_rest.abs() < f64::EPSILON {
239            return 1.0;
240        }
241        self.score / self.score_at_rest
242    }
243}
244
245#[derive(Debug, PartialEq, Eq)]
246pub enum CmaxError {
247    EmptyStream,
248    SizeOverflow,
249    InvalidParameter(&'static str),
250}
251
252impl fmt::Display for CmaxError {
253    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
254        match self {
255            Self::EmptyStream => {
256                formatter.write_str("contrast maximisation needs a non-empty stream")
257            }
258            Self::SizeOverflow => formatter.write_str("image dimensions are too large"),
259            Self::InvalidParameter(name) => {
260                write!(formatter, "{name} must be finite and positive")
261            }
262        }
263    }
264}
265
266impl std::error::Error for CmaxError {}
267
268impl EventStream {
269    /// Accumulates the image of warped events for an explicit motion.
270    ///
271    /// The picture the objective actually scores, returned so it can be looked at — a blurred IWE
272    /// with the "right" parameters is the clearest sign that a warp model does not fit the scene.
273    pub fn iwe(&self, model: WarpModel, params: &[f64]) -> Result<EventFrame, CmaxError> {
274        if params.len() < model.dimensions() {
275            return Err(CmaxError::InvalidParameter("params"));
276        }
277        let (width, height) = self.sensor_size();
278        let plane = width.checked_mul(height).ok_or(CmaxError::SizeOverflow)?;
279        let mut image = vec![0.0_f32; plane];
280        self.accumulate_warped(&model, params, TimeReference::Midpoint, &mut image);
281        EventFrame::intensity(EventFrameData::F32(image), width, height)
282            .map_err(|_| CmaxError::SizeOverflow)
283    }
284
285    /// Finds the motion that makes the warped events sharpest.
286    ///
287    /// Returns the recovered parameters along with the score at rest, so the caller can tell a real
288    /// estimate from the optimiser wandering on a flat landscape — see [`CmaxResult::improvement`].
289    pub fn contrast_maximise(
290        &self,
291        model: WarpModel,
292        config: CmaxConfig,
293    ) -> Result<CmaxResult, CmaxError> {
294        if self.is_empty() {
295            return Err(CmaxError::EmptyStream);
296        }
297        let sigma = config.blur_sigma.unwrap_or(config.objective.default_blur());
298        if !(sigma.is_finite() && sigma >= 0.0) {
299            return Err(CmaxError::InvalidParameter("blur_sigma"));
300        }
301        if !(config.initial_step.is_finite() && config.initial_step > 0.0) {
302            return Err(CmaxError::InvalidParameter("initial_step"));
303        }
304        let (width, height) = self.sensor_size();
305        let plane = width.checked_mul(height).ok_or(CmaxError::SizeOverflow)?;
306        let dims = model.dimensions();
307
308        // One scratch buffer, reused for every evaluation — the optimiser runs hundreds of them and
309        // reallocating a sensor-sized image each time would dominate the cost.
310        let mut image = vec![0.0_f32; plane];
311        let mut scratch = vec![0.0_f32; plane];
312        let mut evaluate = |params: &[f64]| -> f64 {
313            self.accumulate_warped(&model, params, config.time_reference, &mut image);
314            if sigma > 0.0 {
315                blur_in_place(&mut image, width, height, sigma, &mut scratch);
316            }
317            config.objective.score(&image)
318        };
319
320        let zero = vec![0.0; dims];
321        let score_at_rest = evaluate(&zero);
322        let start = match config.initial {
323            Some(initial) => initial[..dims].to_vec(),
324            None => zero,
325        };
326        let (params, score, iterations) = nelder_mead(
327            &mut evaluate,
328            start,
329            config.initial_step,
330            config.max_iterations,
331            config.tolerance,
332        );
333        Ok(CmaxResult {
334            params,
335            score,
336            score_at_rest,
337            iterations,
338        })
339    }
340
341    /// Warps every event and bilinearly scatters it into `image` (which is cleared first).
342    fn accumulate_warped(
343        &self,
344        model: &WarpModel,
345        params: &[f64],
346        reference: TimeReference,
347        image: &mut [f32],
348    ) {
349        image.fill(0.0);
350        let (width, height) = self.sensor_size();
351        let ts = self.ts();
352        let (Some(&first), Some(&last)) = (ts.iter().min(), ts.iter().max()) else {
353            return;
354        };
355        let reference_t = match reference {
356            TimeReference::Midpoint => (first + last) / 2,
357            TimeReference::Start => first,
358            TimeReference::End => last,
359        };
360        // Timestamps are in units of `timestamp_scale_ms` milliseconds; the warp works in seconds
361        // so its parameters read as px/s and rad/s rather than per-tick.
362        let seconds_per_tick = self.timestamp_scale_ms() / 1000.0;
363        let (xs, ys) = (self.xs(), self.ys());
364
365        let warp_range = |range: std::ops::Range<usize>, target: &mut [f32]| {
366            for index in range {
367                let dt = (ts[index] - reference_t) as f64 * seconds_per_tick;
368                let (wx, wy) = model.warp(f64::from(xs[index]), f64::from(ys[index]), dt, params);
369                splat(target, width, height, wx, wy);
370            }
371        };
372
373        // Scattering cannot be parallelised in place — several threads would write the same pixel —
374        // so each chunk accumulates into its own image and the images are summed. That costs one
375        // sensor-sized buffer per thread, which only pays off once there are enough events to
376        // outweigh allocating and reducing them. Below the threshold the serial path is faster.
377        if self.len() < PARALLEL_EVENT_THRESHOLD {
378            warp_range(0..self.len(), image);
379            return;
380        }
381
382        use rayon::prelude::*;
383        let plane = image.len();
384        let chunk = (self.len() / rayon::current_num_threads().max(1)).max(1);
385        let partial = (0..self.len())
386            .into_par_iter()
387            .step_by(chunk)
388            .map(|start| {
389                let end = (start + chunk).min(self.len());
390                let mut local = vec![0.0_f32; plane];
391                warp_range(start..end, &mut local);
392                local
393            })
394            .reduce(
395                || vec![0.0_f32; plane],
396                |mut a, b| {
397                    for (x, y) in a.iter_mut().zip(&b) {
398                        *x += y;
399                    }
400                    a
401                },
402            );
403        image.copy_from_slice(&partial);
404    }
405}
406
407/// Adds one unit of mass at a float position, split across the four surrounding pixels.
408///
409/// Bilinear rather than nearest-neighbour because the objective has to respond to sub-pixel changes
410/// in the warp; rounding to the nearest pixel makes the landscape a staircase with flat treads, and
411/// a derivative-free optimiser stalls on the first one it lands on.
412///
413/// Events landing outside the sensor are dropped. The reference implementation instead folds them
414/// onto pixel (0, 0), which rewards warps that push events off the image — the opposite of what the
415/// objective is meant to measure.
416fn splat(image: &mut [f32], width: usize, height: usize, x: f64, y: f64) {
417    if !(x.is_finite() && y.is_finite()) {
418        return;
419    }
420    let x0 = x.floor();
421    let y0 = y.floor();
422    let fx = (x - x0) as f32;
423    let fy = (y - y0) as f32;
424    let x0 = x0 as i64;
425    let y0 = y0 as i64;
426
427    for (dx, dy, weight) in [
428        (0, 0, (1.0 - fx) * (1.0 - fy)),
429        (1, 0, fx * (1.0 - fy)),
430        (0, 1, (1.0 - fx) * fy),
431        (1, 1, fx * fy),
432    ] {
433        let (px, py) = (x0 + dx, y0 + dy);
434        if px >= 0 && py >= 0 && (px as usize) < width && (py as usize) < height {
435            image[py as usize * width + px as usize] += weight;
436        }
437    }
438}
439
440/// Separable Gaussian blur in place. `scratch` must be the same length as `image`.
441fn blur_in_place(image: &mut [f32], width: usize, height: usize, sigma: f64, scratch: &mut [f32]) {
442    let radius = ((sigma * 3.0).ceil() as usize).clamp(1, MAX_BLUR_RADIUS);
443    let kernel: Vec<f32> = (0..=2 * radius)
444        .map(|i| {
445            let d = i as f64 - radius as f64;
446            (-(d * d) / (2.0 * sigma * sigma)).exp() as f32
447        })
448        .collect();
449    let sum: f32 = kernel.iter().sum();
450    let kernel: Vec<f32> = kernel.iter().map(|k| k / sum).collect();
451
452    // Horizontal into scratch, then vertical back into image. Edges clamp, which keeps the total
453    // mass near constant rather than darkening the border.
454    for y in 0..height {
455        for x in 0..width {
456            let mut total = 0.0;
457            for (k, weight) in kernel.iter().enumerate() {
458                let sx = (x as i64 + k as i64 - radius as i64).clamp(0, width as i64 - 1) as usize;
459                total += image[y * width + sx] * weight;
460            }
461            scratch[y * width + x] = total;
462        }
463    }
464    for y in 0..height {
465        for x in 0..width {
466            let mut total = 0.0;
467            for (k, weight) in kernel.iter().enumerate() {
468                let sy = (y as i64 + k as i64 - radius as i64).clamp(0, height as i64 - 1) as usize;
469                total += scratch[sy * width + x] * weight;
470            }
471            image[y * width + x] = total;
472        }
473    }
474}
475
476/// Nelder-Mead simplex search, maximising `evaluate`.
477///
478/// Derivative-free on purpose: the objective is a noisy function of a discrete event set, and the
479/// reference implementation's own documentation prefers numeric gradients over analytic ones for
480/// exactly that reason. If gradients are not trustworthy, a method that never asks for them is
481/// simpler and no worse.
482fn nelder_mead(
483    evaluate: &mut impl FnMut(&[f64]) -> f64,
484    start: Vec<f64>,
485    step: f64,
486    max_iterations: usize,
487    tolerance: f64,
488) -> (Vec<f64>, f64, usize) {
489    let dims = start.len();
490    if dims == 0 {
491        return (start, 0.0, 0);
492    }
493    // The simplex: the start point plus one offset vertex per dimension.
494    let mut simplex: Vec<Vec<f64>> = Vec::with_capacity(dims + 1);
495    simplex.push(start.clone());
496    for axis in 0..dims {
497        let mut vertex = start.clone();
498        vertex[axis] += step;
499        simplex.push(vertex);
500    }
501    let mut scores: Vec<f64> = simplex.iter().map(|v| evaluate(v)).collect();
502
503    let mut iterations = 0;
504    while iterations < max_iterations {
505        iterations += 1;
506        // Sort best (highest score) first.
507        let mut order: Vec<usize> = (0..simplex.len()).collect();
508        order.sort_by(|&a, &b| {
509            scores[b]
510                .partial_cmp(&scores[a])
511                .unwrap_or(std::cmp::Ordering::Equal)
512        });
513        simplex = order.iter().map(|&i| simplex[i].clone()).collect();
514        scores = order.iter().map(|&i| scores[i]).collect();
515
516        // Converged once every vertex sits within `tolerance` of the best one.
517        let spread = simplex[1..]
518            .iter()
519            .map(|v| {
520                v.iter()
521                    .zip(&simplex[0])
522                    .map(|(a, b)| (a - b).abs())
523                    .fold(0.0_f64, f64::max)
524            })
525            .fold(0.0_f64, f64::max);
526        if spread < tolerance {
527            break;
528        }
529
530        // Centroid of everything but the worst vertex.
531        let worst = simplex.len() - 1;
532        let mut centroid = vec![0.0; dims];
533        for vertex in &simplex[..worst] {
534            for (c, v) in centroid.iter_mut().zip(vertex) {
535                *c += v / worst as f64;
536            }
537        }
538        let combine = |a: &[f64], b: &[f64], t: f64| -> Vec<f64> {
539            a.iter().zip(b).map(|(x, y)| x + t * (x - y)).collect()
540        };
541
542        let reflected = combine(&centroid, &simplex[worst], 1.0);
543        let reflected_score = evaluate(&reflected);
544        if reflected_score > scores[0] {
545            // Better than the best: try stretching further in the same direction.
546            let expanded = combine(&centroid, &simplex[worst], 2.0);
547            let expanded_score = evaluate(&expanded);
548            let (vertex, score) = if expanded_score > reflected_score {
549                (expanded, expanded_score)
550            } else {
551                (reflected, reflected_score)
552            };
553            simplex[worst] = vertex;
554            scores[worst] = score;
555        } else if reflected_score > scores[worst - 1] {
556            simplex[worst] = reflected;
557            scores[worst] = reflected_score;
558        } else {
559            // Reflection did not help: pull the worst vertex toward the centroid instead.
560            let contracted = combine(&centroid, &simplex[worst], -0.5);
561            let contracted_score = evaluate(&contracted);
562            if contracted_score > scores[worst] {
563                simplex[worst] = contracted;
564                scores[worst] = contracted_score;
565            } else {
566                // Nothing worked — shrink the whole simplex toward the best vertex.
567                for index in 1..simplex.len() {
568                    let best = simplex[0].clone();
569                    for (v, b) in simplex[index].iter_mut().zip(&best) {
570                        *v = b + 0.5 * (*v - b);
571                    }
572                    scores[index] = evaluate(&simplex[index]);
573                }
574            }
575        }
576    }
577
578    let best = scores
579        .iter()
580        .enumerate()
581        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
582        .map(|(i, _)| i)
583        .unwrap_or(0);
584    (simplex[best].clone(), scores[best], iterations)
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use crate::representation::RepresentationKind;
591    use crate::EventStreamBuilder;
592
593    /// A moving point: one event per step, travelling at a known velocity in px/s.
594    fn moving_point(vx: f64, vy: f64, steps: usize, duration_us: i64) -> EventStream {
595        let mut builder = EventStreamBuilder::new(64, 64, 0.001);
596        for step in 0..steps {
597            let t = step as i64 * duration_us / steps as i64;
598            let seconds = t as f64 / 1e6;
599            let x = 32.0 + vx * seconds;
600            let y = 32.0 + vy * seconds;
601            builder.push(x.round() as u16, y.round() as u16, t, true);
602        }
603        builder.build()
604    }
605
606    /// A moving edge: a column of events sweeping across, which is what a real scene looks like.
607    fn moving_edge(vx: f64, steps: usize, duration_us: i64) -> EventStream {
608        let mut builder = EventStreamBuilder::new(64, 64, 0.001);
609        for step in 0..steps {
610            let t = step as i64 * duration_us / steps as i64;
611            let x = 16.0 + vx * (t as f64 / 1e6);
612            for y in 8..56u16 {
613                builder.push(x.round() as u16, y, t, true);
614            }
615        }
616        builder.build()
617    }
618
619    #[test]
620    fn splat_conserves_mass_and_splits_by_weight() {
621        let mut image = vec![0.0_f32; 16];
622        // Exactly between four pixels: a quarter each.
623        splat(&mut image, 4, 4, 1.5, 1.5);
624        for (index, expected) in [(5, 0.25), (6, 0.25), (9, 0.25), (10, 0.25)] {
625            assert!((image[index] - expected).abs() < 1e-6, "pixel {index}");
626        }
627        assert!((image.iter().sum::<f32>() - 1.0).abs() < 1e-6);
628
629        // Exactly on a pixel: all of it there.
630        let mut exact = vec![0.0_f32; 16];
631        splat(&mut exact, 4, 4, 2.0, 1.0);
632        assert!((exact[4 + 2] - 1.0).abs() < 1e-6); // row 1, column 2
633        assert!((exact.iter().sum::<f32>() - 1.0).abs() < 1e-6);
634
635        // A quarter of the way across splits 3:1.
636        let mut fractional = vec![0.0_f32; 16];
637        splat(&mut fractional, 4, 4, 1.25, 1.0);
638        assert!((fractional[5] - 0.75).abs() < 1e-6);
639        assert!((fractional[6] - 0.25).abs() < 1e-6);
640    }
641
642    #[test]
643    fn splat_drops_out_of_bounds_rather_than_folding_to_the_corner() {
644        // The reference implementation's bug: escaped events pile up at (0, 0) and reward warps
645        // that push events off the sensor.
646        let mut image = vec![0.0_f32; 16];
647        splat(&mut image, 4, 4, -50.0, -50.0);
648        splat(&mut image, 4, 4, 500.0, 500.0);
649        assert_eq!(image.iter().sum::<f32>(), 0.0);
650        assert_eq!(image[0], 0.0, "nothing may accumulate at the origin");
651
652        // A partly-outside event contributes only its inside weight.
653        let mut edge = vec![0.0_f32; 16];
654        splat(&mut edge, 4, 4, -0.5, 1.0);
655        assert!(edge.iter().sum::<f32>() < 1.0);
656    }
657
658    #[test]
659    fn splat_ignores_non_finite_coordinates() {
660        let mut image = vec![0.0_f32; 16];
661        splat(&mut image, 4, 4, f64::NAN, 1.0);
662        splat(&mut image, 4, 4, 1.0, f64::INFINITY);
663        assert_eq!(image.iter().sum::<f32>(), 0.0);
664    }
665
666    #[test]
667    fn blur_preserves_total_mass() {
668        let mut image = vec![0.0_f32; 64 * 64];
669        image[32 * 64 + 32] = 1.0;
670        let mut scratch = vec![0.0_f32; 64 * 64];
671        blur_in_place(&mut image, 64, 64, 1.5, &mut scratch);
672        assert!((image.iter().sum::<f32>() - 1.0).abs() < 1e-4);
673        // And it actually spread.
674        assert!(image[32 * 64 + 32] < 1.0);
675        assert!(image[32 * 64 + 33] > 0.0);
676    }
677
678    #[test]
679    fn objectives_prefer_a_concentrated_image() {
680        // The minimum property: a sharp image must score above a smeared one with the same mass.
681        let mut sharp = vec![0.0_f32; 100];
682        sharp[50] = 10.0;
683        let spread = vec![0.1_f32; 100];
684        for objective in [
685            Objective::Variance,
686            Objective::SumOfSquares,
687            Objective::SumOfExponentials,
688        ] {
689            assert!(
690                objective.score(&sharp) > objective.score(&spread),
691                "{objective:?} failed to prefer the concentrated image"
692            );
693        }
694    }
695
696    #[test]
697    fn a_warp_at_the_true_velocity_beats_zero_and_a_wrong_guess() {
698        let (vx, vy) = (300.0, 0.0);
699        let stream = moving_point(vx, vy, 40, 40_000);
700        let (width, height) = stream.sensor_size();
701        let mut image = vec![0.0_f32; width * height];
702
703        let score_for = |params: &[f64], image: &mut Vec<f32>| {
704            stream.accumulate_warped(
705                &WarpModel::Translation,
706                params,
707                TimeReference::Midpoint,
708                image,
709            );
710            Objective::Variance.score(image)
711        };
712        let truth = score_for(&[vx, vy], &mut image);
713        let rest = score_for(&[0.0, 0.0], &mut image);
714        let wrong = score_for(&[-vx, 200.0], &mut image);
715        assert!(truth > rest, "true warp {truth} must beat rest {rest}");
716        assert!(
717            truth > wrong,
718            "true warp {truth} must beat a wrong one {wrong}"
719        );
720    }
721
722    #[test]
723    fn recovers_a_known_translation() {
724        // The test the reference implementation does not have: assert the motion comes back.
725        let (vx, vy) = (250.0, -150.0);
726        let stream = moving_edge(vx, 30, 40_000);
727        let result = stream
728            .contrast_maximise(WarpModel::Translation, CmaxConfig::default())
729            .expect("optimisation should succeed");
730        assert!(
731            (result.params[0] - vx).abs() < 60.0,
732            "recovered vx {}, expected {vx}",
733            result.params[0]
734        );
735        assert!(
736            result.improvement() > 1.0,
737            "should beat the static hypothesis"
738        );
739        assert!(result.iterations > 0);
740        // vy is unconstrained for a vertical edge sweeping horizontally — the aperture problem —
741        // so it is deliberately not asserted here. `recovers_translation_in_both_axes` covers it
742        // with a stimulus that constrains both.
743        let _ = vy;
744    }
745
746    #[test]
747    fn recovers_translation_in_both_axes() {
748        // A point track constrains both components, unlike an edge.
749        let (vx, vy) = (200.0, 160.0);
750        let stream = moving_point(vx, vy, 40, 50_000);
751        let result = stream
752            .contrast_maximise(WarpModel::Translation, CmaxConfig::default())
753            .unwrap();
754        assert!(
755            (result.params[0] - vx).abs() < 80.0,
756            "vx {}",
757            result.params[0]
758        );
759        assert!(
760            (result.params[1] - vy).abs() < 80.0,
761            "vy {}",
762            result.params[1]
763        );
764    }
765
766    #[test]
767    fn a_static_scene_recovers_no_motion() {
768        let mut builder = EventStreamBuilder::new(64, 64, 0.001);
769        for step in 0..40i64 {
770            builder.push(20, 20, step * 1000, true);
771            builder.push(40, 40, step * 1000, false);
772        }
773        let result = builder
774            .build()
775            .contrast_maximise(WarpModel::Translation, CmaxConfig::default())
776            .unwrap();
777        // Already sharp, so warping can only smear it: the optimum stays near zero.
778        assert!(result.params[0].abs() < 60.0, "vx {}", result.params[0]);
779        assert!(result.params[1].abs() < 60.0, "vy {}", result.params[1]);
780    }
781
782    #[test]
783    fn every_objective_recovers_the_same_motion() {
784        let vx = 250.0;
785        let stream = moving_edge(vx, 30, 40_000);
786        for objective in [
787            Objective::Variance,
788            Objective::SumOfSquares,
789            Objective::SumOfExponentials,
790        ] {
791            let result = stream
792                .contrast_maximise(
793                    WarpModel::Translation,
794                    CmaxConfig {
795                        objective,
796                        ..CmaxConfig::default()
797                    },
798                )
799                .unwrap();
800            assert!(
801                (result.params[0] - vx).abs() < 100.0,
802                "{objective:?} recovered {}",
803                result.params[0]
804            );
805        }
806    }
807
808    #[test]
809    fn the_time_reference_does_not_change_the_recovered_motion() {
810        // Where events are warped to changes the IWE's position, not the velocity that sharpens it.
811        let vx = 250.0;
812        let stream = moving_edge(vx, 30, 40_000);
813        for reference in [
814            TimeReference::Midpoint,
815            TimeReference::Start,
816            TimeReference::End,
817        ] {
818            let result = stream
819                .contrast_maximise(
820                    WarpModel::Translation,
821                    CmaxConfig {
822                        time_reference: reference,
823                        ..CmaxConfig::default()
824                    },
825                )
826                .unwrap();
827            assert!(
828                (result.params[0] - vx).abs() < 100.0,
829                "{reference:?} recovered {}",
830                result.params[0]
831            );
832        }
833    }
834
835    #[test]
836    fn the_iwe_is_inspectable_and_sharper_at_the_truth() {
837        let vx = 300.0;
838        let stream = moving_edge(vx, 30, 40_000);
839        let sharp = stream.iwe(WarpModel::Translation, &[vx, 0.0]).unwrap();
840        let smeared = stream.iwe(WarpModel::Translation, &[0.0, 0.0]).unwrap();
841        assert_eq!(sharp.shape(), (1, 64, 64));
842        assert_eq!(sharp.kind(), RepresentationKind::Intensity);
843
844        let extent = |frame: &EventFrame| match frame.data() {
845            EventFrameData::F32(values) => values.iter().filter(|&&v| v > 0.01).count(),
846            _ => unreachable!("iwe is always f32"),
847        };
848        // Sharper means the same events occupy fewer pixels.
849        assert!(
850            extent(&sharp) < extent(&smeared),
851            "warped {} vs unwarped {} lit pixels",
852            extent(&sharp),
853            extent(&smeared)
854        );
855    }
856
857    #[test]
858    fn an_empty_stream_is_rejected() {
859        let empty = EventStreamBuilder::new(8, 8, 0.001).build();
860        assert_eq!(
861            empty.contrast_maximise(WarpModel::Translation, CmaxConfig::default()),
862            Err(CmaxError::EmptyStream)
863        );
864    }
865
866    #[test]
867    fn bad_parameters_are_rejected() {
868        let stream = moving_point(100.0, 0.0, 10, 10_000);
869        for config in [
870            CmaxConfig {
871                blur_sigma: Some(f64::NAN),
872                ..CmaxConfig::default()
873            },
874            CmaxConfig {
875                initial_step: 0.0,
876                ..CmaxConfig::default()
877            },
878        ] {
879            assert!(stream
880                .contrast_maximise(WarpModel::Translation, config)
881                .is_err());
882        }
883        assert!(stream.iwe(WarpModel::Translation, &[1.0]).is_err());
884    }
885
886    #[test]
887    fn rotation_has_three_parameters_and_runs() {
888        let camera = Camera::new(100.0, 100.0, 32.0, 32.0);
889        let model = WarpModel::Rotation { camera };
890        assert_eq!(model.dimensions(), 3);
891        let stream = moving_edge(200.0, 20, 30_000);
892        let result = stream
893            .contrast_maximise(
894                model,
895                CmaxConfig {
896                    initial_step: 1.0, // rad/s, not px/s
897                    ..CmaxConfig::default()
898                },
899            )
900            .unwrap();
901        assert_eq!(result.params.len(), 3);
902        assert!(result.score.is_finite());
903    }
904
905    #[test]
906    fn recovers_the_motion_the_simulator_was_given() {
907        // The end-to-end claim: render a pattern moving at a known speed, simulate the events a DVS
908        // would produce, and recover that speed from the events alone. Nothing in the chain is told
909        // the answer — this is the validation a real recording cannot provide, because a recording
910        // has no ground-truth motion attached.
911        use crate::simulate::{Simulator, SimulatorConfig, Upsample};
912
913        let (width, height) = (96usize, 64usize);
914        let pixels_per_second = 200.0_f64;
915        let fps = 500.0_f64;
916        let frames = 24;
917
918        let mut simulator = Simulator::new(
919            width,
920            height,
921            SimulatorConfig {
922                // A clean sensor: this test is about the geometry, not the noise model.
923                sigma_thres: 0.0,
924                leak_rate_hz: 0.0,
925                shot_noise_rate_hz: 0.0,
926                cutoff_hz: 0.0,
927                refractory_us: 0,
928                upsample: Upsample::Off,
929                ..SimulatorConfig::default()
930            },
931        );
932
933        let mut events = Vec::new();
934        for frame in 0..frames {
935            let seconds = frame as f64 / fps;
936            // A vertical bar sweeping right at a known rate.
937            let bar = 12.0 + pixels_per_second * seconds;
938            let mut luma = vec![0.05_f32; width * height];
939            for y in 0..height {
940                for offset in 0..4 {
941                    let x = (bar as usize).saturating_add(offset);
942                    if x < width {
943                        luma[y * width + x] = 0.9;
944                    }
945                }
946            }
947            let slice = simulator.push_frame(&luma, (seconds * 1e6) as i64);
948            events.push(slice);
949        }
950
951        let stream = match events.split_first() {
952            Some((first, rest)) => first.concat(&rest.iter().collect::<Vec<_>>()),
953            None => unreachable!("frames were pushed"),
954        };
955        assert!(
956            stream.len() > 100,
957            "simulator produced {} events",
958            stream.len()
959        );
960
961        let result = stream
962            .contrast_maximise(WarpModel::Translation, CmaxConfig::default())
963            .expect("optimisation should succeed");
964
965        let recovered = result.params[0];
966        assert!(
967            (recovered - pixels_per_second).abs() < pixels_per_second * 0.35,
968            "recovered {recovered:.1} px/s from simulated events, expected {pixels_per_second:.1}"
969        );
970        assert!(
971            result.improvement() > 1.0,
972            "the recovered motion must beat the static hypothesis"
973        );
974    }
975
976    #[test]
977    fn nelder_mead_finds_a_known_maximum() {
978        // The optimiser in isolation: a smooth quadratic with a maximum at (3, -2).
979        let mut evaluate = |p: &[f64]| -(p[0] - 3.0).powi(2) - (p[1] + 2.0).powi(2);
980        let (params, score, iterations) =
981            nelder_mead(&mut evaluate, vec![0.0, 0.0], 1.0, 500, 1e-6);
982        assert!((params[0] - 3.0).abs() < 1e-3, "x {}", params[0]);
983        assert!((params[1] + 2.0).abs() < 1e-3, "y {}", params[1]);
984        assert!(score > -1e-5);
985        assert!(iterations < 500, "should converge before the cap");
986    }
987}