dr-pano: the geometry, from features to cameras
A new crate holding the CPU half of a merge (FR-MRG-10): the grayscale proxy with orientation, the XFeat decoder ported step for step from the reference detectAndCompute, mutual-nearest-neighbour matching, a robust pairwise homography with the focal length read off it, a hand-rolled Levenberg–Marquardt bundle adjustment over every rotation and the focal, the three output projections, and align(), which chains it all and names the frames it could not place rather than guessing (FR-MRG-5). Dependency-free without the xfeat feature — linalg.rs says why the dense algebra is hand-rolled — and tested on synthetic sweeps whose answer is known exactly. The noise test records the single-row degeneracy: one pixel of noise is a tenth of a percent of focal, which is a uniform stretch of the sweep, not a misalignment.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! TRACES: FR-MRG-4
|
||||
//! The surface the composite is drawn on.
|
||||
//!
|
||||
//! A panorama is a set of directions; a picture is a plane. The projection
|
||||
//! is the map between them, and the three offered are the three every
|
||||
//! stitcher offers because each is right for a different field of view:
|
||||
//! perspective keeps straight lines straight and cannot reach 180°;
|
||||
//! cylindrical keeps verticals vertical and stretches nothing horizontally,
|
||||
//! for the wide single row; spherical for anything that also looks up.
|
||||
//!
|
||||
//! Every function here is the *inverse* map — output pixel to direction —
|
||||
//! because that is what a gather needs (`lens.rs` in `dr-pipeline` says
|
||||
//! why a warp is written that way), and it is the function the WGSL warp
|
||||
//! will repeat verbatim. The forward map exists for bounds only.
|
||||
|
||||
use crate::linalg::Vec3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Projection {
|
||||
Perspective,
|
||||
Cylindrical,
|
||||
Spherical,
|
||||
}
|
||||
|
||||
impl Projection {
|
||||
/// Which projection a field of view calls for.
|
||||
///
|
||||
/// Perspective stretches the edges by `1 / cos` of the angle from the
|
||||
/// centre, which is 2× at 60° and unbounded at 90°; the switch is where
|
||||
/// that stretch starts to look like a mistake. Spherical is for a set
|
||||
/// that spans enough vertically that a cylinder would stretch the top
|
||||
/// and bottom the same way.
|
||||
pub fn suggest(horizontal_fov: f64, vertical_fov: f64) -> Projection {
|
||||
if horizontal_fov < 70f64.to_radians() && vertical_fov < 70f64.to_radians() {
|
||||
Projection::Perspective
|
||||
} else if vertical_fov < 100f64.to_radians() {
|
||||
Projection::Cylindrical
|
||||
} else {
|
||||
Projection::Spherical
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction an output point looks along. `scale` is the output's
|
||||
/// focal length in pixels: the radius of the cylinder or sphere, or the
|
||||
/// plane's distance. Coordinates are centred on the projection's origin
|
||||
/// (the direction `+z`).
|
||||
pub fn to_direction(self, scale: f64, u: f64, v: f64) -> Vec3 {
|
||||
match self {
|
||||
Projection::Perspective => Vec3::new(u, v, scale).normalised(),
|
||||
Projection::Cylindrical => {
|
||||
let theta = u / scale;
|
||||
Vec3::new(theta.sin(), v / scale, theta.cos()).normalised()
|
||||
}
|
||||
Projection::Spherical => {
|
||||
let theta = u / scale;
|
||||
let phi = v / scale;
|
||||
Vec3::new(theta.sin() * phi.cos(), phi.sin(), theta.cos() * phi.cos())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a direction lands on the output, or `None` where the
|
||||
/// projection cannot show it (behind a perspective plane, at a
|
||||
/// cylinder's poles).
|
||||
pub fn from_direction(self, scale: f64, d: Vec3) -> Option<(f64, f64)> {
|
||||
let (x, y, z) = (d.x(), d.y(), d.z());
|
||||
match self {
|
||||
Projection::Perspective => (z > 1e-9).then(|| (scale * x / z, scale * y / z)),
|
||||
Projection::Cylindrical => {
|
||||
let r = (x * x + z * z).sqrt();
|
||||
(r > 1e-9).then(|| (scale * x.atan2(z), scale * y / r))
|
||||
}
|
||||
Projection::Spherical => {
|
||||
let r = (x * x + z * z).sqrt();
|
||||
Some((scale * x.atan2(z), scale * y.atan2(r)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The output rectangle a set of frames covers, in centred output pixels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounds {
|
||||
pub min_u: f64,
|
||||
pub min_v: f64,
|
||||
pub max_u: f64,
|
||||
pub max_v: f64,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
pub fn width(&self) -> f64 {
|
||||
self.max_u - self.min_u
|
||||
}
|
||||
pub fn height(&self) -> f64 {
|
||||
self.max_v - self.min_v
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the frames' footprints under `projection`, by walking each
|
||||
/// frame's border.
|
||||
///
|
||||
/// `frame_size` is the frames' width and height in the same pixels the
|
||||
/// cameras' focal length is in. The border is sampled rather than only its
|
||||
/// corners because under a cylinder the widest point of a rolled frame is
|
||||
/// not a corner.
|
||||
pub fn bounds(
|
||||
projection: Projection,
|
||||
scale: f64,
|
||||
cameras: &crate::bundle::Cameras,
|
||||
frame_size: (f64, f64),
|
||||
) -> Option<Bounds> {
|
||||
let (w, h) = frame_size;
|
||||
let mut b: Option<Bounds> = None;
|
||||
let steps = 64;
|
||||
for k in 0..cameras.rotations.len() {
|
||||
for s in 0..steps {
|
||||
let t = s as f64 / steps as f64;
|
||||
for p in [
|
||||
(-w / 2.0 + w * t, -h / 2.0),
|
||||
(-w / 2.0 + w * t, h / 2.0),
|
||||
(-w / 2.0, -h / 2.0 + h * t),
|
||||
(w / 2.0, -h / 2.0 + h * t),
|
||||
] {
|
||||
let d = cameras.bearing(k, p);
|
||||
let Some((u, v)) = projection.from_direction(scale, d) else {
|
||||
continue;
|
||||
};
|
||||
b = Some(match b {
|
||||
None => Bounds {
|
||||
min_u: u,
|
||||
min_v: v,
|
||||
max_u: u,
|
||||
max_v: v,
|
||||
},
|
||||
Some(b) => Bounds {
|
||||
min_u: b.min_u.min(u),
|
||||
min_v: b.min_v.min(v),
|
||||
max_u: b.max_u.max(u),
|
||||
max_v: b.max_v.max(v),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn to_and_from_direction_are_inverses() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_origin_looks_down_z_in_every_projection() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cylinder_maps_ninety_degrees_to_a_quarter_turn_of_pixels() {
|
||||
let d = Vec3::new(1.0, 0.0, 0.0);
|
||||
let (u, v) = Projection::Cylindrical.from_direction(100.0, d).unwrap();
|
||||
assert!((u - 100.0 * std::f64::consts::FRAC_PI_2).abs() < 1e-9);
|
||||
assert_eq!(v, 0.0);
|
||||
assert!(Projection::Perspective.from_direction(100.0, d).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggestion_widens_with_the_field() {
|
||||
assert_eq!(Projection::suggest(0.5, 0.5), Projection::Perspective);
|
||||
assert_eq!(Projection::suggest(2.5, 0.8), Projection::Cylindrical);
|
||||
assert_eq!(Projection::suggest(3.0, 2.5), Projection::Spherical);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user