Files
DarkRoom/core/dr-pano/src/bundle.rs
T
dtourolle 42d11d919b cargo fmt and clippy across the panorama work, and one lint master carried
The dr-face comparison is master's: a negated partial-order test on the
eye box's width, rewritten as the two conditions it meant.
2026-09-19 15:53:06 +02:00

447 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bundle adjustment: every rotation and the focal length, refined together.
//!
//! The pairwise homographies (`homography.rs`) each know about two frames.
//! Chained around a loop they disagree with themselves by the accumulated
//! error, and a twelve-frame sweep chained end to end drifts by a visible
//! amount. This solves for all the rotations at once, against every inlier
//! match of every pair, so the error is spread rather than accumulated —
//! Brown & Lowe's step 4, with the camera model reduced to what a panorama
//! needs: one rotation per frame and one focal length shared by all.
//!
//! Levenberg–Marquardt with a numerical Jacobian. Analytic derivatives of a
//! rotation's projection are not hard, but they are a second place the
//! model is written down, and the model is small: forty parameters, a few
//! thousand residuals, a Jacobian that costs forty residual evaluations.
//! The whole solve is milliseconds. Correctness over cleverness, and one
//! definition of the projection to keep right.
use crate::linalg::{DMat, Mat3, Vec3};
use crate::PanoError;
/// A point in one image, centred on the principal point, in pixels.
pub type Point = (f64, f64);
/// One inlier match between two frames.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Observation {
pub i: usize,
pub j: usize,
pub pi: Point,
pub pj: Point,
}
/// What the adjustment starts from and returns: a rotation per frame
/// (camera to world; frame 0 is the world) and the focal length in pixels.
#[derive(Debug, Clone, PartialEq)]
pub struct Cameras {
pub rotations: Vec<Mat3>,
pub focal: f64,
}
impl Cameras {
/// The unit direction, in world space, that pixel `p` of frame `i` looks
/// along.
pub fn bearing(&self, i: usize, p: Point) -> Vec3 {
self.rotations[i] * Vec3::new(p.0, p.1, self.focal).normalised()
}
/// Where world direction `d` lands in frame `j`, or `None` if it is
/// behind the camera.
pub fn project(&self, j: usize, d: Vec3) -> Option<Point> {
let c = self.rotations[j].transpose() * d;
if c.z() <= 1e-9 {
return None;
}
Some((self.focal * c.x() / c.z(), self.focal * c.y() / c.z()))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AdjustOptions {
pub max_iterations: usize,
/// Residuals beyond this many pixels are down-weighted (Huber), so a
/// mismatch RANSAC let through pulls with bounded force.
pub huber_px: f64,
/// Whether the focal length is a free parameter. Off, it is held at the
/// starting value — for a set whose rotations are all small, the focal
/// length is weakly observable and better taken from the homographies'
/// median than pulled about by noise.
pub refine_focal: bool,
}
impl Default for AdjustOptions {
fn default() -> Self {
AdjustOptions {
max_iterations: 50,
huber_px: 3.0,
refine_focal: true,
}
}
}
/// The adjusted cameras and the fit.
#[derive(Debug, Clone, PartialEq)]
pub struct Adjusted {
pub cameras: Cameras,
/// Root-mean-square reprojection error over all observations, in pixels
/// (unweighted, so an outlier RANSAC missed shows here rather than
/// hiding under its Huber weight).
pub rms_px: f64,
pub iterations: usize,
}
/// Refine `start` against `observations`.
///
/// Frame 0's rotation is held fixed: the world frame is arbitrary and
/// fixing one camera removes the freedom. Every other frame must appear in
/// at least one observation or its rotation is undetermined and the normal
/// equations are singular — the caller (`align`) guarantees it by only
/// adjusting frames a spanning tree reached.
pub fn adjust(
start: Cameras,
observations: &[Observation],
opts: &AdjustOptions,
) -> Result<Adjusted, PanoError> {
let n_frames = start.rotations.len();
if n_frames < 2 || observations.is_empty() {
let rms = rms(&start, observations);
return Ok(Adjusted {
cameras: start,
rms_px: rms,
iterations: 0,
});
}
// Every adjustable frame must be constrained by something, or its
// block of the normal equations is zero and the solve is meaningless —
// checked here, by name, rather than left to surface as a step that
// fails to lower the cost.
let mut seen = vec![false; n_frames];
for o in observations {
seen[o.i] = true;
seen[o.j] = true;
}
if let Some(k) = (1..n_frames).find(|&k| !seen[k]) {
return Err(PanoError::Geometry(format!(
"frame {k} has no observations constraining it"
)));
}
let n_rot = 3 * (n_frames - 1);
let n_params = n_rot + usize::from(opts.refine_focal);
let n_res = 2 * observations.len();
// Parameters are *increments* on the current cameras, re-applied each
// accepted step: rotation k ← exp(δ_k) · rotation k, focal ← f · exp(δ_f).
// Composing on the left keeps the increment in world space, where a
// small rotation means the same thing for every frame.
let apply = |base: &Cameras, x: &[f64]| -> Cameras {
let mut rotations = base.rotations.clone();
for k in 1..n_frames {
let w = Vec3::new(x[3 * (k - 1)], x[3 * (k - 1) + 1], x[3 * (k - 1) + 2]);
rotations[k] = (Mat3::exp(w) * base.rotations[k]).orthonormalised();
}
let focal = if opts.refine_focal {
base.focal * x[n_rot].exp()
} else {
base.focal
};
Cameras { rotations, focal }
};
let residuals = |c: &Cameras, out: &mut Vec<f64>| {
out.clear();
for o in observations {
let d = c.bearing(o.i, o.pi);
match c.project(o.j, d) {
Some((x, y)) => {
out.push(x - o.pj.0);
out.push(y - o.pj.1);
}
None => {
// Behind the camera: as wrong as a residual can be
// without being infinite. The Huber weight caps its pull.
out.push(1e4);
out.push(1e4);
}
}
}
};
let weights = |r: &[f64], out: &mut Vec<f64>| {
out.clear();
for pair in r.chunks_exact(2) {
let m = (pair[0] * pair[0] + pair[1] * pair[1]).sqrt();
let w = if m > opts.huber_px {
opts.huber_px / m
} else {
1.0
};
out.push(w);
out.push(w);
}
};
// The robust cost itself, not the weighted sum of squares: the weights
// above are the IRLS linearisation for one step, and comparing two
// steps by sums taken under different weights would accept the wrong
// ones. Huber: quadratic within the threshold, linear beyond it.
let cost = |r: &[f64]| -> f64 {
r.chunks_exact(2)
.map(|pair| {
let m = (pair[0] * pair[0] + pair[1] * pair[1]).sqrt();
if m <= opts.huber_px {
m * m
} else {
2.0 * opts.huber_px * m - opts.huber_px * opts.huber_px
}
})
.sum()
};
let mut cameras = start;
let mut r = Vec::with_capacity(n_res);
let mut w = Vec::with_capacity(n_res);
residuals(&cameras, &mut r);
weights(&r, &mut w);
let mut current = cost(&r);
let mut lambda = 1e-3;
let mut jac = vec![0.0f64; n_res * n_params];
let mut r_plus = Vec::with_capacity(n_res);
let zero = vec![0.0f64; n_params];
let mut iterations = 0;
for _ in 0..opts.max_iterations {
iterations += 1;
// Numerical Jacobian about the current cameras (x = 0).
const H: f64 = 1e-6;
for p in 0..n_params {
let mut x = zero.clone();
x[p] = H;
let c_plus = apply(&cameras, &x);
residuals(&c_plus, &mut r_plus);
for (k, (rp, r0)) in r_plus.iter().zip(&r).enumerate() {
jac[k * n_params + p] = (rp - r0) / H;
}
}
// Normal equations, weighted: (JᵀWJ + λ·diag) δ = −JᵀWr.
let mut a = DMat::zeros(n_params);
let mut b = vec![0.0f64; n_params];
for k in 0..n_res {
let row = &jac[k * n_params..(k + 1) * n_params];
let wk = w[k];
for p in 0..n_params {
b[p] -= wk * row[p] * r[k];
for q in 0..n_params {
a[(p, q)] += wk * row[p] * row[q];
}
}
}
// Try steps with increasing damping until one lowers the cost.
let mut accepted = false;
for _ in 0..10 {
let mut damped = a.clone();
for p in 0..n_params {
let d = a[(p, p)];
damped[(p, p)] = d + lambda * d.max(1e-9);
}
let Some(delta) = damped.solve_spd(&b) else {
return Err(PanoError::Geometry(
"the adjustment's normal equations are singular: a frame has no \
observations constraining it"
.into(),
));
};
let candidate = apply(&cameras, &delta);
residuals(&candidate, &mut r_plus);
let c_new = cost(&r_plus);
if c_new < current {
let improvement = (current - c_new) / current.max(1e-12);
let step: f64 = delta.iter().map(|d| d * d).sum::<f64>().sqrt();
cameras = candidate;
std::mem::swap(&mut r, &mut r_plus);
weights(&r, &mut w);
current = c_new;
lambda = (lambda / 3.0).max(1e-9);
accepted = true;
// Converged when a *lightly damped* step no longer helps. A
// heavily damped step is small by construction and would
// pass an improvement test long before the minimum.
if step < 1e-10 || (improvement < 1e-8 && lambda < 1e-2) {
return Ok(Adjusted {
rms_px: rms(&cameras, observations),
cameras,
iterations,
});
}
break;
}
lambda *= 5.0;
}
if !accepted {
break;
}
}
Ok(Adjusted {
rms_px: rms(&cameras, observations),
cameras,
iterations,
})
}
/// Unweighted RMS reprojection error in pixels.
pub fn rms(c: &Cameras, observations: &[Observation]) -> f64 {
if observations.is_empty() {
return 0.0;
}
let sum: f64 = observations
.iter()
.map(|o| match c.project(o.j, c.bearing(o.i, o.pi)) {
Some((x, y)) => (x - o.pj.0).powi(2) + (y - o.pj.1).powi(2),
None => 1e8,
})
.sum();
(sum / observations.len() as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
/// A synthetic sweep: `n` cameras panned by `step` radians each with a
/// little pitch and roll, `f` pixels, and matches between neighbours
/// from a cloud of world directions.
fn sweep(n: usize, step: f64, f: f64, noise_px: f64) -> (Cameras, Vec<Observation>) {
let mut rotations = Vec::new();
for k in 0..n {
let yaw = step * k as f64;
let pitch = 0.01 * ((k * 7) % 3) as f64;
let roll = 0.005 * ((k * 5) % 4) as f64;
let r = Mat3::rotation(Vec3::new(0.0, 1.0, 0.0), yaw)
* Mat3::rotation(Vec3::new(1.0, 0.0, 0.0), pitch)
* Mat3::rotation(Vec3::new(0.0, 0.0, 1.0), roll);
rotations.push(r);
}
let truth = Cameras {
rotations,
focal: f,
};
// World directions: a fan across the whole sweep.
let mut obs = Vec::new();
let mut seed = 12345u64;
let mut rnd = || {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((seed >> 33) as f64 / (1u64 << 31) as f64) - 0.5
};
let total = step * (n as f64 - 1.0);
for _ in 0..400 * n {
let yaw = rnd() * (total + 0.8) + total / 2.0;
let pitch = rnd() * 0.5;
let d = Vec3::new(
yaw.sin() * pitch.cos(),
pitch.sin(),
yaw.cos() * pitch.cos(),
)
.normalised();
// Visible in which frames? Within ±0.35 f of centre.
let mut seen: Vec<(usize, Point)> = Vec::new();
for k in 0..n {
if let Some(p) = truth.project(k, d) {
if p.0.abs() < 0.35 * f && p.1.abs() < 0.25 * f {
seen.push((k, (p.0 + rnd() * noise_px, p.1 + rnd() * noise_px)));
}
}
}
for a in 0..seen.len() {
for b in a + 1..seen.len() {
obs.push(Observation {
i: seen[a].0,
j: seen[b].0,
pi: seen[a].1,
pj: seen[b].1,
});
}
}
}
(truth, obs)
}
fn angle_between(a: Mat3, b: Mat3) -> f64 {
(a.transpose() * b).log().norm()
}
#[test]
fn a_perturbed_start_converges_back_to_the_truth() {
let (truth, obs) = sweep(6, 0.3, 1400.0, 0.0);
assert!(obs.len() > 500);
// Perturb every rotation but the first by ~1°, and the focal by 5%.
let mut start = truth.clone();
for k in 1..6 {
let w = Vec3::new(0.01, -0.015, 0.008) * (k as f64 / 3.0);
start.rotations[k] = Mat3::exp(w) * start.rotations[k];
}
start.focal *= 1.05;
let before = rms(&start, &obs);
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
assert!(out.rms_px < 1e-3, "rms {} (was {before})", out.rms_px);
assert!(
(out.cameras.focal - 1400.0).abs() < 0.5,
"focal {}",
out.cameras.focal
);
for k in 0..6 {
let err = angle_between(out.cameras.rotations[k], truth.rotations[k]);
assert!(err < 1e-5, "frame {k} off by {err} rad");
}
}
#[test]
fn noise_is_averaged_rather_than_accumulated() {
let (truth, obs) = sweep(8, 0.25, 1400.0, 1.0);
let mut start = truth.clone();
for k in 1..8 {
start.rotations[k] =
Mat3::exp(Vec3::new(0.0, 0.004 * k as f64, 0.0)) * start.rotations[k];
}
let out = adjust(start, &obs, &AdjustOptions::default()).expect("solvable");
// ±0.5 px of uniform noise on every coordinate has an RMS of 0.41 px
// per axis, so the fit's RMS over both axes should sit near 0.58 and
// cannot be much below it.
assert!(out.rms_px < 0.7, "rms {}", out.rms_px);
// The focal length and the sweep are nearly degenerate for a
// single row: only the perspective inside each overlap pins the
// focal, and a pixel of noise is worth about a tenth of a percent of
// it. What that error does is scale every yaw by the same factor —
// a uniform stretch of the panorama, invisible in the result — so the
// absolute rotation error grows linearly along the sweep and is not
// the measure of the solve. The residual after removing that stretch
// is.
let f_ratio = out.cameras.focal / 1400.0;
assert!((f_ratio - 1.0).abs() < 5e-3, "focal {}", out.cameras.focal);
for k in 0..8 {
let yaw_k = 0.25 * k as f64;
let expected_stretch = (f_ratio - 1.0).abs() * yaw_k;
let err = angle_between(out.cameras.rotations[k], truth.rotations[k]);
assert!(
err < expected_stretch + 1.5e-4,
"frame {k} off by {err} rad, {expected_stretch} of it the focal's"
);
}
}
#[test]
fn a_frame_without_observations_is_refused() {
let (truth, mut obs) = sweep(4, 0.3, 1400.0, 0.0);
obs.retain(|o| o.i != 3 && o.j != 3);
let err = adjust(truth, &obs, &AdjustOptions::default()).unwrap_err();
assert!(matches!(err, PanoError::Geometry(_)));
}
}