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.
This commit is contained in:
2026-09-19 15:53:06 +02:00
parent 67f225beba
commit 42d11d919b
23 changed files with 330 additions and 123 deletions
+39 -16
View File
@@ -115,7 +115,11 @@ impl Alignment {
/// when [`Self::is_complete`].
pub fn cameras(&self) -> Cameras {
Cameras {
rotations: self.rotations.iter().map(|r| r.unwrap_or(Mat3::IDENTITY)).collect(),
rotations: self
.rotations
.iter()
.map(|r| r.unwrap_or(Mat3::IDENTITY))
.collect(),
focal: self.focal,
}
}
@@ -130,7 +134,9 @@ impl Alignment {
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
let n = frames.len();
if n < 2 {
return Err(PanoError::Input("a panorama needs at least two frames".into()));
return Err(PanoError::Input(
"a panorama needs at least two frames".into(),
));
}
let centre = |k: usize, i: usize| -> (f64, f64) {
@@ -177,10 +183,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
continue;
};
let needed = (8.0 + 0.3 * matches.len() as f64).ceil() as usize;
log::debug!(
"pair {i}-{j}: {} inliers, {needed} needed",
inliers.len()
);
log::debug!("pair {i}-{j}: {} inliers, {needed} needed", inliers.len());
if inliers.len() <= needed || inliers.len() < opts.min_inliers {
continue;
}
@@ -218,7 +221,10 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
.filter_map(|l| homography::focal_from_homography(&l.h))
.filter(|f| f.is_finite() && *f > 0.0)
.collect();
let longest = frames.iter().map(|f| f.width.max(f.height) as f64).fold(0.0, f64::max);
let longest = frames
.iter()
.map(|f| f.width.max(f.height) as f64)
.fold(0.0, f64::max);
let focal = if !estimates.is_empty() {
estimates.sort_by(f64::total_cmp);
let median = estimates[estimates.len() / 2];
@@ -236,10 +242,10 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
let mut rotations: Vec<Option<Mat3>> = vec![None; n];
let mut unaligned = Vec::new();
if links.is_empty() {
for k in 0..n {
for (k, &matched) in matched_any.iter().enumerate() {
unaligned.push((
k,
if matched_any[k] {
if matched {
Unaligned::NoOverlap
} else {
Unaligned::NoMatches
@@ -341,7 +347,13 @@ mod tests {
/// Frames of a synthetic sweep: world directions with random unit
/// descriptors, each frame seeing the ones in its field of view.
fn synthetic_sweep(n: usize, step: f64, f: f64, w: usize, h: usize) -> (Vec<Features>, Cameras) {
fn synthetic_sweep(
n: usize,
step: f64,
f: f64,
w: usize,
h: usize,
) -> (Vec<Features>, Cameras) {
let mut seed = 777u64;
let mut rnd = || {
seed = seed
@@ -355,7 +367,10 @@ mod tests {
* Mat3::rotation(Vec3::new(1.0, 0.0, 0.0), 0.02 * ((k % 3) as f64 - 1.0))
})
.collect();
let truth = Cameras { rotations, focal: f };
let truth = Cameras {
rotations,
focal: f,
};
let total = step * (n as f64 - 1.0);
let mut frames: Vec<Features> = (0..n)
.map(|_| Features {
@@ -368,20 +383,24 @@ mod tests {
for _ in 0..600 * 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());
let d = Vec3::new(
yaw.sin() * pitch.cos(),
pitch.sin(),
yaw.cos() * pitch.cos(),
);
let desc: Vec<f32> = (0..DESCRIPTOR_LEN).map(|_| rnd() as f32).collect();
let norm = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
let desc: Vec<f32> = desc.iter().map(|v| v / norm).collect();
for k in 0..n {
for (k, frame) in frames.iter_mut().enumerate() {
if let Some(p) = truth.project(k, d) {
let (x, y) = (p.0 + w as f64 / 2.0, p.1 + h as f64 / 2.0);
if x >= 0.0 && x < w as f64 && y >= 0.0 && y < h as f64 {
frames[k].keypoints.push(Keypoint {
frame.keypoints.push(Keypoint {
x: (x + rnd() * 0.6) as f32,
y: (y + rnd() * 0.6) as f32,
score: 1.0,
});
frames[k].descriptors.extend_from_slice(&desc);
frame.descriptors.extend_from_slice(&desc);
}
}
}
@@ -402,7 +421,11 @@ mod tests {
assert!((out.focal - 1400.0).abs() < 15.0, "focal {}", out.focal);
assert!(out.rms_px < 1.0, "rms {}", out.rms_px);
// Relative rotations match the truth's, whichever frame is the root.
let root = out.rotations.iter().position(|r| *r == Some(Mat3::IDENTITY)).unwrap();
let root = out
.rotations
.iter()
.position(|r| *r == Some(Mat3::IDENTITY))
.unwrap();
for k in 0..6 {
let rel_truth = truth.rotations[root].transpose() * truth.rotations[k];
let rel_out = out.rotations[k].unwrap();
+17 -5
View File
@@ -325,7 +325,10 @@ mod tests {
* Mat3::rotation(Vec3::new(0.0, 0.0, 1.0), roll);
rotations.push(r);
}
let truth = Cameras { rotations, focal: f };
let truth = Cameras {
rotations,
focal: f,
};
// World directions: a fan across the whole sweep.
let mut obs = Vec::new();
@@ -340,8 +343,12 @@ mod tests {
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();
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 {
@@ -383,7 +390,11 @@ mod tests {
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);
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");
@@ -395,7 +406,8 @@ mod tests {
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];
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
+9 -7
View File
@@ -108,9 +108,7 @@ pub fn ransac_homography(
let Some(h) = dlt(&sample.map(|k| pairs[k])) else {
continue;
};
let inliers: Vec<usize> = (0..n)
.filter(|&k| agrees(&h, pairs[k], thr2))
.collect();
let inliers: Vec<usize> = (0..n).filter(|&k| agrees(&h, pairs[k], thr2)).collect();
if best.as_ref().is_none_or(|(b, _)| inliers.len() > b.len()) {
// Adapt: enough iterations to have drawn one all-inlier sample
// with probability 0.99, given the ratio seen so far.
@@ -333,7 +331,11 @@ mod tests {
pairs.push((p, ((rng.below(1000) as f64 - 500.0) / 1000.0, 0.1)));
}
let robust = ransac_homography(&pairs, 0.003, 500, 3).expect("found");
assert!(robust.inliers.len() >= 55, "{} inliers", robust.inliers.len());
assert!(
robust.inliers.len() >= 55,
"{} inliers",
robust.inliers.len()
);
assert!(robust.inliers.iter().all(|&k| k < 60));
}
@@ -351,9 +353,9 @@ mod tests {
let est = focal_from_homography(&h).expect("estimable");
assert!((est - f).abs() / f < 1e-6, "{est}");
let back = rotation_from_homography(&h, f);
for i in 0..3 {
for j in 0..3 {
assert!((back.0[i][j] - m[i][j]).abs() < 1e-9);
for (row, truth) in back.0.iter().zip(&m) {
for (a, b) in row.iter().zip(truth) {
assert!((a - b).abs() < 1e-9);
}
}
}
+4 -1
View File
@@ -22,7 +22,10 @@ impl Gray {
/// frames because they were converted differently.
pub fn from_rgba8(rgba: &[u8], width: usize, height: usize) -> Gray {
let n = width * height;
assert!(rgba.len() >= n * 4, "rgba buffer is short for {width}×{height}");
assert!(
rgba.len() >= n * 4,
"rgba buffer is short for {width}×{height}"
);
let data = rgba[..n * 4]
.chunks_exact(4)
.map(|p| {
+6 -1
View File
@@ -339,7 +339,12 @@ mod tests {
#[test]
fn cholesky_solves_a_small_spd_system() {
// A = Bᵀ B for a random-ish B is SPD by construction.
let b = [[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 4.0], [1.0, 1.0, 1.0]];
let b = [
[2.0, 1.0, 0.0],
[1.0, 3.0, 1.0],
[0.0, 1.0, 4.0],
[1.0, 1.0, 1.0],
];
let mut a = DMat::zeros(3);
for i in 0..3 {
for j in 0..3 {
+5 -4
View File
@@ -16,7 +16,7 @@
use crate::features::{Features, DESCRIPTOR_LEN};
const _: () = assert!(DESCRIPTOR_LEN % 8 == 0);
const _: () = assert!(DESCRIPTOR_LEN.is_multiple_of(8));
/// A correspondence: keypoint `a` in the first image matches keypoint `b`
/// in the second, with the cosine similarity of their descriptors.
@@ -64,9 +64,10 @@ pub fn match_features(a: &Features, b: &Features, min_similarity: f32) -> Vec<Ma
let best_ab: Vec<(usize, f32)> = sim
.chunks_exact(nb)
.map(|row| {
row.iter()
.enumerate()
.fold((0usize, f32::MIN), |acc, (j, &s)| if s > acc.1 { (j, s) } else { acc })
row.iter().enumerate().fold(
(0usize, f32::MIN),
|acc, (j, &s)| if s > acc.1 { (j, s) } else { acc },
)
})
.collect();
let mut best_ba = vec![(0usize, f32::MIN); nb];
+14 -3
View File
@@ -151,18 +151,29 @@ mod tests {
#[test]
fn to_and_from_direction_are_inverses() {
for proj in [Projection::Perspective, Projection::Cylindrical, Projection::Spherical] {
for proj in [
Projection::Perspective,
Projection::Cylindrical,
Projection::Spherical,
] {
for (u, v) in [(0.0, 0.0), (300.0, -200.0), (-900.0, 450.0)] {
let d = proj.to_direction(1000.0, u, v);
let (bu, bv) = proj.from_direction(1000.0, d).expect("in front");
assert!((bu - u).abs() < 1e-9 && (bv - v).abs() < 1e-9, "{proj:?} {u} {v}");
assert!(
(bu - u).abs() < 1e-9 && (bv - v).abs() < 1e-9,
"{proj:?} {u} {v}"
);
}
}
}
#[test]
fn the_origin_looks_down_z_in_every_projection() {
for proj in [Projection::Perspective, Projection::Cylindrical, Projection::Spherical] {
for proj in [
Projection::Perspective,
Projection::Cylindrical,
Projection::Spherical,
] {
let d = proj.to_direction(500.0, 0.0, 0.0);
assert!((d.z() - 1.0).abs() < 1e-12);
}
+7 -3
View File
@@ -43,7 +43,10 @@ impl XFeat {
}
/// From the two exports on disk.
pub fn from_paths(landscape: &std::path::Path, portrait: &std::path::Path) -> Result<Self, PanoError> {
pub fn from_paths(
landscape: &std::path::Path,
portrait: &std::path::Path,
) -> Result<Self, PanoError> {
let l = std::fs::read(landscape).map_err(PanoError::ModelRead)?;
let p = std::fs::read(portrait).map_err(PanoError::ModelRead)?;
Self::from_bytes(&l, &p)
@@ -80,8 +83,9 @@ impl XFeat {
let (fitted, scale) = image.fitted(in_w, in_h);
let padded = fitted.padded(in_w, in_h);
let input = ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
.expect("shape matches the buffer by construction");
let input =
ndarray::Array::from_shape_vec(ndarray::IxDyn(&[1, 1, in_h, in_w]), padded.data)
.expect("shape matches the buffer by construction");
let tensor = ort::value::Tensor::from_array(input).map_err(PanoError::Inference)?;
let outputs = session
.run(ort::inputs![tensor])