Commit: 46c05e5
Parent: 84678f9

Verify RANSAC correctness with synthetic-data unit tests

Mårten Åsberg committed on 2026-08-03 at 18:13
Real-data matching was passing geometric verification for almost no
pairs (median best-inlier-count of 0 despite abundant raw LightGlue
matches), and CPU vs WebGPU gave identical results, ruling out an EP
correctness bug. Rather than keep re-reading the code, add an actual
test: split ransac.rs into a small ort-free library target (it has no
dependency on ort) so `cargo test --lib` can verify it in isolation
without needing a working ONNX Runtime/EP setup at all.

Result: RANSAC recovers >=95% inliers from synthetic correspondences
with known-clean epipolar geometry, and correctly rejects unrelated
random point sets. The geometry math is confirmed correct, which
redirects suspicion to the real-data coordinate/matching pipeline.

Also add a diagnostic dump of sample matched coordinates for the pair
with the most raw matches, so they can be visually cross-checked
against the actual source images.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/exhaustive_matcher.rs +25 -1
diff --git a/src/exhaustive_matcher.rs b/src/exhaustive_matcher.rs
index 1f8443e..af7fa7c 100644
@@ -6,10 +6,11 @@ use ort::session::Session;
use ort::value::TensorRef;
use rayon::prelude::*;
use aliked_colmap::ransac::{self, RansacResult};
use crate::database::{self, image_pair_to_pair_id};
use crate::models::{self, Device};
use crate::progress;
use crate::ransac::{self, RansacResult};
pub struct ExhaustiveMatcherConfig {
pub database_path: PathBuf,
@@ -113,6 +114,8 @@ fn run_lightglue(
struct PendingPair {
pair_id: i64,
name_lo: String,
name_hi: String,
matched: Vec<(usize, usize)>,
pts0: Vec<(f32, f32)>,
pts1: Vec<(f32, f32)>,
@@ -179,6 +182,8 @@ pub fn run(cfg: ExhaustiveMatcherConfig) -> Result<()> {
pending.push(PendingPair {
pair_id,
name_lo: lo.name.clone(),
name_hi: hi.name.clone(),
matched: ordered_matched,
pts0,
pts1,
@@ -187,6 +192,25 @@ pub fn run(cfg: ExhaustiveMatcherConfig) -> Result<()> {
}
match_pb.finish();
// Diagnostic: dump sample matched coordinates for the pair with the most raw matches
// (most likely to have genuine overlap), so they can be visually cross-checked
// against the actual images -- do these pixel coordinates really land on the same
// physical point in both photos?
if let Some(sample) = pending.iter().max_by_key(|p| p.matched.len()) {
eprintln!(
"[diag] sample matches for {} <-> {} ({} raw matches):",
sample.name_lo,
sample.name_hi,
sample.matched.len()
);
for k in (0..sample.pts0.len()).step_by((sample.pts0.len() / 10).max(1)) {
eprintln!(
"[diag] {} @ ({:.1}, {:.1}) <-> {} @ ({:.1}, {:.1})",
sample.name_lo, sample.pts0[k].0, sample.pts0[k].1, sample.name_hi, sample.pts1[k].0, sample.pts1[k].1
);
}
}
let ransac_pb = progress::new(pending.len() as u64, "pairs");
let ransac_results: Vec<Option<RansacResult>> = pending
.par_iter()
src/lib.rs +4 -0
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..62d293e
@@ -0,0 +1,4 @@
//! `ransac` has no dependency on `ort`, so it's split out into this library target
//! (separate from the `ort`-dependent binary) so its correctness can be verified with
//! `cargo test --lib` without needing a working ONNX Runtime / execution provider setup.
pub mod ransac;
src/main.rs +0 -1
diff --git a/src/main.rs b/src/main.rs
index 4f03145..55faa62 100644
@@ -3,7 +3,6 @@ mod exhaustive_matcher;
mod feature_extractor;
mod models;
mod progress;
mod ransac;
use std::path::PathBuf;
src/ransac.rs +104 -0
diff --git a/src/ransac.rs b/src/ransac.rs
index 18b15ae..7cd45f4 100644
@@ -190,3 +190,107 @@ pub fn ransac_fundamental_matrix(
inliers: final_inliers,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Deterministic pseudo-random f64 in [0, 1), seeded by index -- avoids pulling in a
/// distribution API just for a self-contained test.
fn hash01(i: u32) -> f64 {
let x = (i as f64) * 12.9898;
(x.sin() * 43758.5453).fract().abs()
}
#[test]
fn recovers_fundamental_matrix_from_synthetic_correspondences() {
// Two pinhole cameras sharing intrinsics and orientation, camera1 translated
// `baseline` along +x relative to camera0 (a simple rectified stereo rig). Points
// projected from the same 3D point into both cameras are true epipolar
// correspondences by construction, so a correct implementation should recover
// nearly all of them as inliers.
let fx = 800.0_f64;
let fy = 800.0_f64;
let cx = 320.0_f64;
let cy = 240.0_f64;
let baseline = 1.0_f64;
let project = |(x, y, z): (f64, f64, f64), tx: f64| -> (f64, f64) {
let x = x - tx;
(fx * x / z + cx, fy * y / z + cy)
};
let points_3d: Vec<(f64, f64, f64)> = (0..200u32)
.map(|i| {
(
hash01(i * 3) * 4.0 - 2.0,
hash01(i * 3 + 1) * 4.0 - 2.0,
5.0 + hash01(i * 3 + 2) * 5.0,
)
})
.collect();
let pts0: Vec<(f32, f32)> = points_3d
.iter()
.map(|&p| {
let (x, y) = project(p, 0.0);
(x as f32, y as f32)
})
.collect();
let pts1: Vec<(f32, f32)> = points_3d
.iter()
.map(|&p| {
let (x, y) = project(p, baseline);
(x as f32, y as f32)
})
.collect();
let result = ransac_fundamental_matrix(
&pts0,
&pts1,
DEFAULT_MAX_ITERATIONS,
DEFAULT_INLIER_THRESHOLD,
DEFAULT_CONFIDENCE,
)
.expect("RANSAC should find a model for clean synthetic correspondences");
assert!(
result.inliers.len() as f64 >= 0.95 * pts0.len() as f64,
"expected almost all {} synthetic correspondences to be inliers, got {}",
pts0.len(),
result.inliers.len()
);
}
#[test]
fn rejects_pairs_with_no_valid_geometry() {
// Two independently-random point sets have no consistent epipolar relationship,
// so RANSAC should never find enough inliers to pass MIN_CORRESPONDENCES.
let pts0: Vec<(f32, f32)> = (0..200u32)
.map(|i| ((hash01(i * 2) * 640.0) as f32, (hash01(i * 2 + 1) * 480.0) as f32))
.collect();
let pts1: Vec<(f32, f32)> = (0..200u32)
.map(|i| {
(
(hash01(i * 2 + 1000) * 640.0) as f32,
(hash01(i * 2 + 1001) * 480.0) as f32,
)
})
.collect();
let result = ransac_fundamental_matrix(
&pts0,
&pts1,
DEFAULT_MAX_ITERATIONS,
DEFAULT_INLIER_THRESHOLD,
DEFAULT_CONFIDENCE,
);
if let Some(result) = result {
assert!(
result.inliers.len() < 20,
"random, unrelated point sets should not produce a large inlier set, got {}",
result.inliers.len()
);
}
}
}