Skip to main content

cumulus3d_align/
gravity.rs

1/*
2 * gravity.rs
3 *
4 * Copyright (c) 2026 ParkSangWoo
5 *
6 * Author(s):
7 *
8 *      ParkSangWoo <dev.parksangwoo@gmail.com>
9 *
10 *
11 * This file is part of cumulus3d.
12 *
13 * Licensed under the Apache License, Version 2.0 (the "License");
14 * you may not use this file except in compliance with the License.
15 * You may obtain a copy of the License at
16 *
17 *      http://www.apache.org/licenses/LICENSE-2.0
18 *
19 * Unless required by applicable law or agreed to in writing, software
20 * distributed under the License is distributed on an "AS IS" BASIS,
21 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 * See the License for the specific language governing permissions and
23 * limitations under the License.
24 *
25 * Third-party notices: parts of the algorithms, default parameters and data
26 * formats in this file follow other open-source projects. Their copyright
27 * notices and licenses are reproduced in THIRD_PARTY_NOTICES.md.
28 *
29 * SPDX-License-Identifier: Apache-2.0
30 */
31
32//! Gravity prior for GPS ENU fits whose positions are (nearly) collinear.
33//!
34//! A Sim3 fitted to camera centers on a straight line leaves the rotation about that line unobservable: the scene can end
35//! up rolled about the flight line. When that happens the free roll is chosen from gravity: (a) a dominant ground plane
36//! fitted to the sparse points (cameras above it, normal -> +Up), or, failing that, (b) the mean optical axis pointing down.
37//! Scale and translation stay those of the GPS fit. With non-degenerate GPS nothing here changes the fit.
38
39use crate::umeyama::umeyama;
40use cumulus3d_core::linalg::svd3;
41use cumulus3d_core::{Mat3, Quat, Reconstruction, Sim3, Vec3};
42
43/// Ratio of the 2nd to the 1st singular value of the centred positions below which they count as collinear.
44pub const COLLINEAR_RATIO: f64 = 0.05;
45
46/// Which gravity prior fixed the free roll.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum GravityPrior {
49    /// A dominant ground plane of the sparse points (inlier count).
50    Ground {
51        /// Points on the plane.
52        inliers: usize,
53    },
54    /// The mean optical axis made to point down.
55    ViewDirection,
56    /// No prior was usable (the line is vertical or no points / cameras); the fit is left as is.
57    None,
58}
59
60/// Outcome of a degenerate-GPS fit.
61#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct GravityResolution {
63    /// σ2/σ1 of the centred GPS positions.
64    pub ratio: f64,
65    /// Prior used.
66    pub prior: GravityPrior,
67}
68
69/// σ2/σ1 of the centred positions (1.0 when fewer than 3 points or no spread, so those are never "degenerate" here).
70pub fn collinearity_ratio(pts: &[Vec3]) -> f64 {
71    line_axis(pts).map_or(1.0, |(_, r, _)| r)
72}
73
74/// `Some(ratio)` when the positions are collinear (ratio < [`COLLINEAR_RATIO`]).
75pub fn degenerate_ratio(pts: &[Vec3]) -> Option<f64> {
76    let r = collinearity_ratio(pts);
77    (r < COLLINEAR_RATIO).then_some(r)
78}
79
80/// (principal direction, σ2/σ1, centroid).
81fn line_axis(pts: &[Vec3]) -> Option<(Vec3, f64, Vec3)> {
82    if pts.len() < 3 {
83        return None;
84    }
85    let mu = pts.iter().sum::<Vec3>() / pts.len() as f64;
86    let mut cov = Mat3::zeros();
87    for p in pts {
88        let d = p - mu;
89        cov += d * d.transpose();
90    }
91    let (_, s, v) = svd3(&cov)?;
92    if s[0] <= 1e-18 {
93        return None;
94    }
95    Some((v.column(0).into_owned(), (s[1] / s[0]).sqrt(), mu))
96}
97
98/// Umeyama Sim3 `src -> dst`; when `dst` is collinear the roll about the line is resolved with the gravity prior of `rec`
99/// (`src` are the model-frame positions). `None` if Umeyama fails.
100pub fn fit_enu_sim3(rec: &Reconstruction, src: &[Vec3], dst: &[Vec3]) -> Option<(Sim3, Option<GravityResolution>)> {
101    let t = umeyama(src, dst, true)?;
102    match degenerate_ratio(dst) {
103        None => Some((t, None)),
104        Some(ratio) => {
105            let (t2, prior) = resolve_roll(rec, &t, dst);
106            Some((t2, Some(GravityResolution { ratio, prior })))
107        }
108    }
109}
110
111/// Umeyama Sim3 between two frames that already share the same Up (e.g. two ENU models). With collinear `dst` the roll
112/// about the line is chosen so that +Z maps to +Z.
113pub fn fit_same_up_sim3(src: &[Vec3], dst: &[Vec3]) -> Option<Sim3> {
114    let t = umeyama(src, dst, true)?;
115    let Some((d, ratio, mu)) = line_axis(dst) else { return Some(t) };
116    if ratio >= COLLINEAR_RATIO {
117        return Some(t);
118    }
119    let up = Vec3::z();
120    let moved = t.rotation.rotate(&up);
121    Some(roll_towards(&t, &d, &mu, &moved, &up).unwrap_or(t))
122}
123
124/// Resolves the free roll of `t` (model -> ENU) about the line through `dst`.
125pub fn resolve_roll(rec: &Reconstruction, t: &Sim3, dst: &[Vec3]) -> (Sim3, GravityPrior) {
126    let Some((d, _, mu)) = line_axis(dst) else { return (*t, GravityPrior::None) };
127    let up = Vec3::z();
128    if d.dot(&up).abs() > 0.98 {
129        return (*t, GravityPrior::None); // vertical line: Up is already fixed by the line itself.
130    }
131    // Registered camera centres and optical axes in ENU.
132    let (mut cams, mut axes) = (Vec::new(), Vec::new());
133    for id in rec.registered_images() {
134        if let Some(p) = rec.world_to_cam(id) {
135            cams.push(t.transform_point(&p.center()));
136            axes.push(t.rotation.rotate(&p.viewing_direction()));
137        }
138    }
139    let pts: Vec<Vec3> = {
140        let all: Vec<Vec3> = rec.points3d().map(|(_, p)| t.transform_point(&p.xyz)).filter(|p| p.iter().all(|v| v.is_finite())).collect();
141        let step = (all.len() / MAX_PLANE_POINTS).max(1);
142        all.into_iter().step_by(step).collect()
143    };
144    let mean_axis = axes.iter().sum::<Vec3>();
145    if let Some((n, inliers)) = ground_plane(&pts, &cams) {
146        if let Some(t2) = roll_towards(t, &d, &mu, &n, &up) {
147            // Sanity: with the ground horizontal, drone cameras must look down on average.
148            let a = t2.rotation.rotate(&t.rotation.conjugate().rotate(&mean_axis));
149            if axes.is_empty() || a.z <= 0.0 {
150                return (t2, GravityPrior::Ground { inliers });
151            }
152        }
153    }
154    if mean_axis.norm() > 1e-9 {
155        if let Some(t2) = roll_towards(t, &d, &mu, &mean_axis, &(-up)) {
156            return (t2, GravityPrior::ViewDirection);
157        }
158    }
159    (*t, GravityPrior::None)
160}
161
162/// `t` followed by the rotation about the axis `d` through `mu` that takes the direction `from` closest to `to`.
163/// `None` when either has (almost) no component perpendicular to the axis.
164fn roll_towards(t: &Sim3, d: &Vec3, mu: &Vec3, from: &Vec3, to: &Vec3) -> Option<Sim3> {
165    let perp = |v: &Vec3| v - d * d.dot(v);
166    let (a, b) = (perp(from), perp(to));
167    if a.norm() < 0.2 * from.norm().max(1e-12) || b.norm() < 0.2 * to.norm().max(1e-12) {
168        return None;
169    }
170    let ang = d.dot(&a.cross(&b)).atan2(a.dot(&b));
171    let q = Quat::from_axis_angle(d, ang);
172    Some(Sim3::new(t.scale, q.hamilton(&t.rotation).normalized(), q.rotate(&(t.translation - mu)) + mu))
173}
174
175const MAX_PLANE_POINTS: usize = 6000;
176const PLANE_ITERS: usize = 600;
177
178/// Dominant ground plane: unit normal pointing to the cameras' side and the inlier count. RANSAC + least squares; planes
179/// whose camera side is ambiguous are rejected and planes with many points behind them (roofs, walls) score low.
180fn ground_plane(pts: &[Vec3], cams: &[Vec3]) -> Option<(Vec3, usize)> {
181    let n = pts.len();
182    if n < 30 || cams.len() < 2 {
183        return None;
184    }
185    let med = |mut v: Vec<f64>| {
186        v.sort_by(|a, b| a.total_cmp(b));
187        v[v.len() / 2]
188    };
189    let centre =
190        Vec3::new(med(pts.iter().map(|p| p.x).collect()), med(pts.iter().map(|p| p.y).collect()), med(pts.iter().map(|p| p.z).collect()));
191    let thr = 0.03 * med(pts.iter().map(|p| (p - centre).norm()).collect());
192    if thr.is_nan() || thr <= 0.0 {
193        return None;
194    }
195    // Orients (n, off) so that cameras are on the positive side; None if they are not clearly above.
196    let orient = |nrm: Vec3, off: f64| -> Option<(Vec3, f64)> {
197        let sd: Vec<f64> = cams.iter().map(|c| nrm.dot(c) - off).collect();
198        let pos = sd.iter().filter(|s| **s > 0.0).count();
199        let (nrm, off, pos) = if pos * 2 < sd.len() { (-nrm, -off, sd.len() - pos) } else { (nrm, off, pos) };
200        (pos as f64 >= 0.9 * sd.len() as f64).then_some((nrm, off))
201    };
202    let score = |nrm: &Vec3, off: f64| -> (f64, usize) {
203        let (mut inl, mut behind) = (0usize, 0usize);
204        for p in pts {
205            let s = nrm.dot(p) - off;
206            if s.abs() <= thr {
207                inl += 1;
208            } else if s < 0.0 {
209                behind += 1;
210            }
211        }
212        (inl as f64 - 2.0 * behind as f64, inl)
213    };
214    let mut state = 0x9E37_79B9_7F4A_7C15u64;
215    let mut next = move || {
216        state ^= state << 13;
217        state ^= state >> 7;
218        state ^= state << 17;
219        (state % n as u64) as usize
220    };
221    let mut best: Option<(f64, Vec3, f64)> = None;
222    for _ in 0..PLANE_ITERS {
223        let (a, b, c) = (pts[next()], pts[next()], pts[next()]);
224        let nv = (b - a).cross(&(c - a));
225        let len = nv.norm();
226        if len < 1e-12 {
227            continue;
228        }
229        let nrm = nv / len;
230        let Some((nrm, off)) = orient(nrm, nrm.dot(&a)) else { continue };
231        let (s, _) = score(&nrm, off);
232        if best.as_ref().is_none_or(|b| s > b.0) {
233            best = Some((s, nrm, off));
234        }
235    }
236    let (_, mut nrm, mut off) = best?;
237    for _ in 0..3 {
238        let inl: Vec<Vec3> = pts.iter().filter(|p| (nrm.dot(p) - off).abs() <= thr).copied().collect();
239        if inl.len() < 10 {
240            return None;
241        }
242        let mu = inl.iter().sum::<Vec3>() / inl.len() as f64;
243        let mut cov = Mat3::zeros();
244        for p in &inl {
245            cov += (p - mu) * (p - mu).transpose();
246        }
247        let (_, _, v) = svd3(&cov)?;
248        let cand = v.column(2).into_owned();
249        let (n2, o2) = orient(cand, cand.dot(&mu))?;
250        nrm = n2;
251        off = o2;
252    }
253    let (s, inl) = score(&nrm, off);
254    (s > 0.0 && inl >= 10 && inl as f64 >= 0.15 * n as f64).then_some((nrm, inl))
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::geodesy::EnuFrame;
261    use crate::model_aligner::{align_to_gps, EnuOrigin, ModelAlignerOptions};
262    use cumulus3d_core::io::GpsRecord;
263    use cumulus3d_core::{Camera, CameraModelKind, Image, Rigid3, TrackEntry};
264
265    struct Lcg(u64);
266    impl Lcg {
267        fn f(&mut self) -> f64 {
268            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
269            (self.0 >> 11) as f64 / (1u64 << 53) as f64
270        }
271        fn r(&mut self, lo: f64, hi: f64) -> f64 {
272            lo + (hi - lo) * self.f()
273        }
274    }
275
276    /// Camera-to-world rotation (columns x right, y down, z = view direction) for a camera looking along `view`.
277    fn look(view: Vec3) -> Mat3 {
278        let z = view.normalize();
279        let x = z.cross(&Vec3::z()).normalize();
280        let y = z.cross(&x);
281        Mat3::from_columns(&[x, y, z])
282    }
283
284    /// Cameras on one straight line (three passes, different yaw, pitch -60 deg) over a ground plane with buildings,
285    /// expressed in an arbitrary model frame. Returns (reconstruction, ENU camera positions, names, enu_to_model).
286    fn scene(line: &[Vec3]) -> (Reconstruction, Vec<String>, Sim3) {
287        let mut rng = Lcg(7);
288        let rot = Quat::from_axis_angle(&Vec3::new(0.3, -0.5, 0.8).normalize(), 2.1);
289        let e2m = Sim3::new(0.37, rot, Vec3::new(5.0, -3.0, 11.0));
290        let mut rec = Reconstruction::new();
291        rec.add_camera_own_rig(Camera::new(1, CameraModelKind::Pinhole, 1000, 800, vec![800.0, 800.0, 500.0, 400.0]).unwrap()).unwrap();
292        let mut names = Vec::new();
293        for (i, c) in line.iter().enumerate() {
294            let yaw = (i % 3) as f64 * 0.6 - 0.6;
295            let pitch = -60f64.to_radians();
296            let view = Vec3::new(yaw.sin() * pitch.cos(), yaw.cos() * pitch.cos(), pitch.sin());
297            let rc = look(view); // cam -> ENU world
298            let r_m = rc.transpose() * e2m.rotation.to_rotation_matrix().transpose();
299            let cm = e2m.transform_point(c);
300            let q = Quat::from_rotation_matrix(&r_m);
301            let pts: Vec<_> = (0..4).map(|k| cumulus3d_core::Vec2::new(10.0 + k as f64, 20.0)).collect();
302            let name = format!("img_{i:03}.jpg");
303            rec.add_image_own_frame(Image::new(i as u32 + 1, &name, 1, pts), Some(Rigid3::new(q, -(r_m * cm)))).unwrap();
304            rec.register_image(i as u32 + 1).unwrap();
305            names.push(name);
306        }
307        // Ground z = 0 (noisy) and three buildings with walls and roofs.
308        let mut pts = Vec::new();
309        for _ in 0..1500 {
310            pts.push(Vec3::new(rng.r(-20.0, 100.0), rng.r(10.0, 110.0), rng.r(-0.1, 0.1)));
311        }
312        for (cx, cy, h) in [(30.0, 60.0, 15.0), (60.0, 80.0, 20.0), (80.0, 40.0, 10.0)] {
313            for _ in 0..150 {
314                let (u, v, w) = (rng.r(-5.0, 5.0), rng.r(-5.0, 5.0), rng.r(0.0, h));
315                pts.push(Vec3::new(cx + u, cy + v, w)); // walls (one coordinate pinned below) and interior
316                pts.push(Vec3::new(cx + [-5.0, 5.0][(w as usize) % 2], cy + v, w));
317                pts.push(Vec3::new(cx + u, cy + v, h)); // roof
318            }
319        }
320        for p in pts {
321            rec.add_point3d(e2m.transform_point(&p), Vec::<TrackEntry>::new(), [0, 0, 0]).unwrap();
322        }
323        (rec, names, e2m)
324    }
325
326    fn line_cams() -> Vec<Vec3> {
327        (0..30).map(|i| Vec3::new((i % 10) as f64 * 76.0 / 9.0, 0.0, 40.0)).collect()
328    }
329
330    fn ground_normal_error_deg(rec: &Reconstruction, sim: &Sim3) -> (f64, bool) {
331        // Fit the true ground (z = 0 points are the first 1500 added).
332        let g: Vec<Vec3> = rec.points3d().take(1500).map(|(_, p)| sim.transform_point(&p.xyz)).collect();
333        let mu = g.iter().sum::<Vec3>() / g.len() as f64;
334        let mut cov = Mat3::zeros();
335        for p in &g {
336            cov += (p - mu) * (p - mu).transpose();
337        }
338        let (_, _, v) = svd3(&cov).unwrap();
339        let n = v.column(2).into_owned();
340        let ang = n.dot(&Vec3::z()).abs().min(1.0).acos().to_degrees();
341        let above = rec.registered_images().iter().all(|i| sim.transform_point(&rec.projection_center(*i).unwrap()).z > mu.z);
342        (ang, above)
343    }
344
345    #[test]
346    fn collinear_gps_ground_prior_recovers_up() {
347        let line = line_cams();
348        let (rec, _, _) = scene(&line);
349        // Cameras were generated from `line` in order, so image i+1 sits at line[i].
350        let src: Vec<Vec3> = (1..=line.len() as u32).map(|i| rec.projection_center(i).unwrap()).collect();
351        assert!(degenerate_ratio(&line).is_some());
352        let plain = umeyama(&src, &line, true).unwrap();
353        let (bad, _) = ground_normal_error_deg(&rec, &plain);
354        assert!(bad > 10.0, "plain Umeyama should leave the roll arbitrary, got {bad} deg");
355        let (t, g) = fit_enu_sim3(&rec, &src, &line).unwrap();
356        let g = g.unwrap();
357        assert!(matches!(g.prior, GravityPrior::Ground { .. }), "{g:?}");
358        let (err, above) = ground_normal_error_deg(&rec, &t);
359        assert!(err < 3.0 && above, "ground normal off by {err} deg, cameras above: {above}");
360        assert!((t.scale - plain.scale).abs() < 1e-9);
361    }
362
363    #[test]
364    fn collinear_gps_view_direction_fallback() {
365        let line = line_cams();
366        let (rec, _, _) = scene(&line);
367        // No points: the ground prior is unavailable, the optical axis decides.
368        let ids = rec.point3d_ids();
369        let mut bare = Reconstruction::new();
370        bare.add_camera_own_rig(rec.camera(1).unwrap().clone()).unwrap();
371        for i in rec.registered_images() {
372            bare.add_image_own_frame(rec.image(i).unwrap().clone(), rec.world_to_cam(i)).unwrap();
373            bare.register_image(i).unwrap();
374        }
375        assert!(!ids.is_empty());
376        let src: Vec<Vec3> = (1..=line.len() as u32).map(|i| bare.projection_center(i).unwrap()).collect();
377        let (t, g) = fit_enu_sim3(&bare, &src, &line).unwrap();
378        assert_eq!(g.unwrap().prior, GravityPrior::ViewDirection);
379        let mean: Vec3 =
380            bare.registered_images().iter().map(|i| t.rotation.rotate(&bare.world_to_cam(*i).unwrap().viewing_direction())).sum();
381        assert!(mean.z < 0.0);
382        // The mean axis (perpendicular to the line) is sent to straight down, so it is clearly pointing down.
383        assert!(mean.z / mean.norm() < -0.9);
384    }
385
386    #[test]
387    fn align_to_gps_collinear_uses_gravity() {
388        let line = line_cams();
389        let (mut rec, names, _) = scene(&line);
390        let enu = EnuFrame::new(37.5, 127.0, 50.0);
391        let gps: Vec<GpsRecord> = names
392            .iter()
393            .zip(&line)
394            .map(|(n, c)| {
395                let (lat, lon, alt) = enu.enu_to_lla(c);
396                GpsRecord { name: n.clone(), lat, lon, alt }
397            })
398            .collect();
399        let opts = ModelAlignerOptions { origin: EnuOrigin::Explicit { lat: 37.5, lon: 127.0, alt: 50.0 }, ..Default::default() };
400        let a = align_to_gps(&mut rec, &gps, &opts).unwrap();
401        assert!(matches!(a.gravity.map(|g| g.prior), Some(GravityPrior::Ground { .. })));
402        let (err, above) = ground_normal_error_deg(&rec, &Sim3::identity());
403        assert!(err < 3.0 && above, "{err} {above}");
404    }
405
406    #[test]
407    fn non_degenerate_gps_unchanged() {
408        let mut rng = Lcg(3);
409        let pos: Vec<Vec3> = (0..24).map(|_| Vec3::new(rng.r(0.0, 80.0), rng.r(0.0, 60.0), rng.r(30.0, 45.0))).collect();
410        assert!(degenerate_ratio(&pos).is_none());
411        let (rec, _, _) = scene(&pos);
412        let src: Vec<Vec3> = (1..=pos.len() as u32).map(|i| rec.projection_center(i).unwrap()).collect();
413        let (t, g) = fit_enu_sim3(&rec, &src, &pos).unwrap();
414        assert!(g.is_none());
415        assert_eq!(t, umeyama(&src, &pos, true).unwrap());
416    }
417
418    #[test]
419    fn same_up_fit_keeps_roll_zero() {
420        let line = line_cams();
421        let moved: Vec<Vec3> = line.iter().map(|p| p * 1.5 + Vec3::new(3.0, 4.0, 5.0)).collect();
422        let t = fit_same_up_sim3(&line, &moved).unwrap();
423        assert!((t.rotation.rotate(&Vec3::z()) - Vec3::z()).norm() < 1e-6);
424    }
425}