1use std::fmt;
35
36use crate::camera::Camera;
37use crate::representation::{EventFrame, EventFrameData};
38use crate::EventStream;
39
40const MAX_BLUR_RADIUS: usize = 32;
42
43const SOE_MAX_EXPONENT: f64 = 32.0;
47
48const PARALLEL_EVENT_THRESHOLD: usize = 20_000;
51
52#[derive(Clone, Copy, Debug, PartialEq)]
55pub enum WarpModel {
56 Translation,
61 Rotation { camera: Camera },
67}
68
69impl WarpModel {
70 pub fn translation() -> Self {
72 Self::Translation
73 }
74
75 pub fn dimensions(&self) -> usize {
77 match self {
78 Self::Translation => 2,
79 Self::Rotation { .. } => 3,
80 }
81 }
82
83 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
108pub enum Objective {
109 #[default]
111 Variance,
112 SumOfSquares,
115 SumOfExponentials,
118}
119
120impl Objective {
121 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 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 iwe.iter()
166 .map(|&v| f64::from(v).min(SOE_MAX_EXPONENT).exp())
167 .sum::<f64>()
168 / n
169 }
170 }
171 }
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
176pub enum TimeReference {
177 #[default]
180 Midpoint,
181 Start,
183 End,
185}
186
187#[derive(Clone, Copy, Debug, PartialEq)]
189pub struct CmaxConfig {
190 pub objective: Objective,
191 pub time_reference: TimeReference,
192 pub blur_sigma: Option<f64>,
194 pub initial: Option<[f64; 3]>,
196 pub initial_step: f64,
199 pub max_iterations: usize,
200 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 initial_step: 50.0,
213 max_iterations: 200,
214 tolerance: 1e-3,
215 }
216 }
217}
218
219#[derive(Clone, Debug, PartialEq)]
221pub struct CmaxResult {
222 pub params: Vec<f64>,
224 pub score: f64,
226 pub score_at_rest: f64,
229 pub iterations: usize,
230}
231
232impl CmaxResult {
233 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 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 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 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 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 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 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
407fn 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
440fn 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 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
476fn 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 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 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 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 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(¢roid, &simplex[worst], 1.0);
543 let reflected_score = evaluate(&reflected);
544 if reflected_score > scores[0] {
545 let expanded = combine(¢roid, &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 let contracted = combine(¢roid, &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 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 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 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 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 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); assert!((exact.iter().sum::<f32>() - 1.0).abs() < 1e-6);
634
635 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 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 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 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 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 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 let _ = vy;
744 }
745
746 #[test]
747 fn recovers_translation_in_both_axes() {
748 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 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 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 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, ..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 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 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 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 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}