Skip to main content

cumulus3d_align/
model_aligner.rs

1/*
2 * model_aligner.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//! GPS ENU alignment: fits the projection centers of registered images to the ENU coordinates of the GPS reference points
33//! with a robust Sim3.
34//!
35//! On failure it returns `Err` and leaves the model untouched (it does not proceed silently with empty output).
36
37use crate::error::{AlignError, Result};
38use crate::geodesy::EnuFrame;
39use crate::gravity::{degenerate_ratio, resolve_roll, GravityResolution};
40use crate::umeyama::{estimate_sim3_ransac, median, robust_umeyama, RankCheck, RobustUmeyamaOptions};
41use cumulus3d_core::io::{read_gps_file, GpsRecord};
42use cumulus3d_core::ransac::RansacParams;
43use cumulus3d_core::{ImageId, Reconstruction, Sim3, Vec3};
44use std::collections::{HashMap, HashSet};
45use std::path::Path;
46
47/// ENU origin selection.
48#[derive(Clone, Copy, Debug, PartialEq, Default)]
49pub enum EnuOrigin {
50    /// Default behavior: the first line of the GPS list.
51    #[default]
52    FirstRecord,
53    /// Origin fixed for the whole session (latitude°, longitude°, altitude m). Avoids the origin changing from zone to zone.
54    Explicit {
55        /// Latitude (°).
56        lat: f64,
57        /// Longitude (°).
58        lon: f64,
59        /// Ellipsoidal height (m).
60        alt: f64,
61    },
62}
63
64/// model_aligner options. Defaults = script invocation (`--alignment_max_error 3`, min_common_images 3).
65#[derive(Clone, Debug, PartialEq)]
66pub struct ModelAlignerOptions {
67    /// Inlier distance threshold (m) (distance between transformed camera center and GPS ENU ≤ 3 m).
68    pub max_error: f64,
69    /// Minimum number of common images needed for alignment.
70    pub min_common_images: usize,
71    /// ENU origin selection.
72    pub origin: EnuOrigin,
73    /// Confidence, iteration count and seed. The `max_error` field is ignored and the value above is used.
74    pub ransac: RansacParams,
75    /// Minimal-sample degeneracy test method.
76    pub rank_check: RankCheck,
77}
78
79impl Default for ModelAlignerOptions {
80    fn default() -> Self {
81        Self {
82            max_error: 3.0,
83            min_common_images: 3,
84            origin: EnuOrigin::FirstRecord,
85            ransac: RansacParams::default(),
86            rank_check: RankCheck::Uncentered,
87        }
88    }
89}
90
91/// Alignment result.
92#[derive(Clone, Debug)]
93pub struct GpsAlignment {
94    /// model_to_enu.
95    pub sim3: Sim3,
96    /// ENU frame used (including the origin).
97    pub enu: EnuFrame,
98    /// Common images (file order): (image id, name, ENU position).
99    pub common: Vec<(ImageId, String, Vec3)>,
100    /// Inlier flags in the same order as `common`.
101    pub inlier_mask: Vec<bool>,
102    /// Number of inliers.
103    pub num_inliers: usize,
104    /// Number of RANSAC trials.
105    pub num_trials: usize,
106    /// ‖C' − g‖ after alignment (for each name in the file that is in the model and has a pose; no filtering).
107    pub errors: Vec<(String, f64)>,
108    /// Mean alignment error (m).
109    pub mean_error: f64,
110    /// Median alignment error (m).
111    pub median_error: f64,
112    /// Set when the GPS positions are collinear: the roll about the line was fixed by a gravity prior.
113    pub gravity: Option<GravityResolution>,
114}
115
116/// GPS list to (name, ENU). Error if the origin is `FirstRecord` but the list is empty.
117pub fn gps_to_enu(records: &[GpsRecord], origin: EnuOrigin) -> Result<(EnuFrame, Vec<(String, Vec3)>)> {
118    let frame = match origin {
119        EnuOrigin::FirstRecord => {
120            let r = records.first().ok_or(AlignError::TooFewReferences { found: 0, required: 1 })?;
121            EnuFrame::new(r.lat, r.lon, r.alt)
122        }
123        EnuOrigin::Explicit { lat, lon, alt } => EnuFrame::new(lat, lon, alt),
124    };
125    let v = records.iter().map(|r| (r.name.clone(), frame.lla_to_enu(r.lat, r.lon, r.alt))).collect();
126    Ok((frame, v))
127}
128
129/// Estimates only the alignment Sim3 (model unchanged).
130pub fn estimate_gps_alignment(rec: &Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
131    if opts.max_error.is_nan() || opts.max_error <= 0.0 {
132        return Err(AlignError::BadMaxError(opts.max_error));
133    }
134    // 2. Reference positions < 3, so fail.
135    if gps.len() < 3 {
136        return Err(AlignError::TooFewReferences { found: gps.len(), required: 3 });
137    }
138    let (enu, refs) = gps_to_enu(gps, opts.origin)?;
139
140    // 4. Common images (file order, names match, has pose, duplicates excluded).
141    let by_name: HashMap<&str, ImageId> = rec.images().map(|im| (im.name.as_str(), im.image_id)).collect();
142    let mut seen = HashSet::new();
143    let mut common = Vec::new();
144    let (mut src, mut dst) = (Vec::new(), Vec::new());
145    for (name, g) in &refs {
146        let Some(&id) = by_name.get(name.as_str()) else { continue };
147        let Some(c) = rec.projection_center(id) else { continue };
148        if !seen.insert(id) {
149            continue;
150        }
151        common.push((id, name.clone(), *g));
152        src.push(c);
153        dst.push(*g);
154    }
155    // 5.
156    let required = opts.min_common_images.max(3);
157    if common.len() < required {
158        return Err(AlignError::TooFewCommonImages { found: common.len(), required });
159    }
160    // 6. Collinear GPS: RANSAC samples are all degenerate, so fit robustly on all pairs and fix the free roll by gravity.
161    let degenerate = degenerate_ratio(&dst);
162    let (sim3, inlier_mask, num_inliers, num_trials, gravity) = if let Some(ratio) = degenerate {
163        let ro = RobustUmeyamaOptions { min_threshold: opts.max_error, ..Default::default() };
164        let r = robust_umeyama(&src, &dst, &ro).ok_or(AlignError::RansacFailed { inliers: 0 })?;
165        if r.num_inliers < 3 {
166            return Err(AlignError::RansacFailed { inliers: r.num_inliers });
167        }
168        let (sim3, prior) = resolve_roll(rec, &r.sim3, &dst);
169        (sim3, r.inlier_mask, r.num_inliers, 0, Some(GravityResolution { ratio, prior }))
170    } else {
171        let ropts = RansacParams { max_error: opts.max_error, ..opts.ransac.clone() };
172        let rep = estimate_sim3_ransac(&src, &dst, &ropts, opts.rank_check);
173        let num_inliers = rep.support.num_inliers;
174        let sim3 = match (rep.success, rep.model) {
175            (true, Some(m)) if num_inliers >= 3 => m,
176            _ => return Err(AlignError::RansacFailed { inliers: num_inliers }),
177        };
178        (sim3, rep.inlier_mask, num_inliers, rep.num_trials, None)
179    };
180
181    // 8. Alignment error (all names in the file, no filter; duplicate lines are each counted).
182    let mut errors = Vec::new();
183    for (name, g) in &refs {
184        let Some(&id) = by_name.get(name.as_str()) else { continue };
185        let Some(c) = rec.projection_center(id) else { continue };
186        errors.push((name.clone(), (sim3.transform_point(&c) - g).norm()));
187    }
188    let mut ev: Vec<f64> = errors.iter().map(|e| e.1).collect();
189    let mean_error = if ev.is_empty() { f64::NAN } else { ev.iter().sum::<f64>() / ev.len() as f64 };
190    // Design decision: median for an even count. Use the mean of the middle two.
191    let median_error = median(&mut ev);
192
193    Ok(GpsAlignment { sim3, enu, common, inlier_mask, num_inliers, num_trials, errors, mean_error, median_error, gravity })
194}
195
196/// Applies to the model (poses and points) if estimation succeeds. On failure leaves the model unchanged and returns `Err`.
197pub fn align_to_gps(rec: &mut Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
198    let a = estimate_gps_alignment(rec, gps, opts)?;
199    rec.transform(&a.sim3);
200    Ok(a)
201}
202
203/// GPS file path version.
204pub fn align_to_gps_file(rec: &mut Reconstruction, path: impl AsRef<Path>, opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
205    let gps = read_gps_file(path)?;
206    align_to_gps(rec, &gps, opts)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::geodesy::EnuFrame;
213    use crate::umeyama::tests::{gauss, rand_vec, random_sim3};
214    use cumulus3d_core::{Camera, CameraModelKind, Image, Quat, Rigid3, TrackEntry};
215    use rand::RngExt;
216
217    /// Builds an ENU trajectory (3 drones × positions) and moves it to "model coordinates" with an arbitrary Sim3:
218    /// the reconstruction + GPS list.
219    fn scene(num_pos: usize, seed: u64) -> (Reconstruction, Vec<GpsRecord>, Vec<Vec3>, Sim3) {
220        let mut rng = cumulus3d_core::ransac::make_rng(Some(seed));
221        let enu = EnuFrame::new(37.5, 127.0, 50.0);
222        let enu_to_model = random_sim3(&mut rng);
223        let mut rec = Reconstruction::new();
224        let cam = Camera::new(1, CameraModelKind::Pinhole, 1000, 800, vec![800.0, 800.0, 500.0, 400.0]).unwrap();
225        rec.add_camera_own_rig(cam).unwrap();
226        let mut gps = Vec::new();
227        let mut centers = Vec::new();
228        let mut id = 1;
229        for p in 0..num_pos {
230            for (k, dx) in [-20.0, 0.0, 20.0].iter().enumerate() {
231                let c_enu = Vec3::new(p as f64 * 8.0 + dx, p as f64 * 2.0 + dx * 0.5, 30.0 + 3.0 * k as f64);
232                let c_model = enu_to_model.transform_point(&c_enu);
233                // Camera looking down (−U) + a small random rotation.
234                let down = Quat::from_axis_angle(&Vec3::x(), std::f64::consts::PI);
235                let small = Quat::from_axis_angle(&rand_vec(&mut rng, 1.0).normalize(), rng.random_range(-0.2..0.2));
236                // The model-frame rotation must also be accounted for to look down: R_cam = small·down·R_mᵀ.
237                let rot = small * down * enu_to_model.rotation.conjugate();
238                let r = rot.to_rotation_matrix();
239                let pose = Rigid3::new(rot, -(r * c_model));
240                let pts: Vec<_> = (0..20).map(|i| cumulus3d_core::Vec2::new(10.0 + i as f64, 20.0)).collect();
241                let name = format!("cam{k}/img_{p:04}.jpg");
242                rec.add_image_own_frame(Image::new(id, &name, 1, pts), Some(pose)).unwrap();
243                rec.register_image(id).unwrap();
244                let (lat, lon, alt) = enu.enu_to_lla(&c_enu);
245                gps.push(GpsRecord { name, lat, lon, alt });
246                centers.push(c_enu);
247                id += 1;
248            }
249        }
250        // A few observations (for checking reprojection invariance).
251        for i in 0..10u32 {
252            let x = enu_to_model.transform_point(&Vec3::new(10.0 + i as f64, 5.0, 0.0));
253            let pid = rec.add_point3d(x, vec![], [0, 0, 0]).unwrap();
254            for img in 1..=6u32 {
255                let pose = rec.world_to_cam(img).unwrap();
256                let cam = rec.camera(1).unwrap();
257                if let Some(_xy) = cam.cam_to_img(&pose.transform_point(&x)) {
258                    rec.add_observation(pid, TrackEntry::new(img, i)).unwrap();
259                }
260            }
261        }
262        (rec, gps, centers, enu_to_model)
263    }
264
265    /// Projected pixels of all observations.
266    fn projections(rec: &Reconstruction) -> Vec<cumulus3d_core::Vec2> {
267        let mut out = Vec::new();
268        for id in rec.point3d_ids() {
269            let p = rec.point3d(id).unwrap();
270            for e in &p.track {
271                let pose = rec.world_to_cam(e.image_id).unwrap();
272                let cam = rec.camera(1).unwrap();
273                out.push(cam.cam_to_img(&pose.transform_point(&p.xyz)).unwrap());
274            }
275        }
276        out
277    }
278
279    #[test]
280    fn aligns_synthetic_model() {
281        let (mut rec, gps, centers, enu_to_model) = scene(10, 11);
282        let before = projections(&rec);
283        assert!(before.len() > 10);
284        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(1), ..Default::default() }, ..Default::default() };
285        let a = align_to_gps(&mut rec, &gps, &opts).unwrap();
286        assert_eq!(a.num_inliers, 30);
287        // The origin = first line position, so the first image center is ENU (0,0,0) and the rest are shifted from the original ENU by c0.
288        let c0 = centers[0];
289        for (i, c) in centers.iter().enumerate() {
290            let got = rec.projection_center(i as u32 + 1).unwrap();
291            assert!((got - (c - c0)).norm() < 1e-3, "{i}: {got:?} vs {:?}", c - c0);
292        }
293        assert!(a.median_error < 1e-3 && a.mean_error < 1e-3);
294        let expect = enu_to_model.inverse();
295        assert!((a.sim3.scale - expect.scale).abs() / expect.scale < 1e-6);
296        // Reprojection error is invariant after applying the Sim3.
297        let after = projections(&rec);
298        assert_eq!(before.len(), after.len());
299        for (b, a) in before.iter().zip(&after) {
300            assert!((b - a).norm() < 1e-8, "{b:?} vs {a:?}");
301        }
302        // C' = s R C + t.
303        let c_before = enu_to_model.transform_point(&centers[4]);
304        assert!((a.sim3.transform_point(&c_before) - rec.projection_center(5).unwrap()).norm() < 1e-9);
305    }
306
307    #[test]
308    fn explicit_origin_and_order_rule() {
309        let (rec, mut gps, centers, _) = scene(6, 12);
310        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(2), ..Default::default() }, ..Default::default() };
311        // Changing the line order changes the origin.
312        gps.swap(0, 5);
313        let mut r1 = rec.clone();
314        align_to_gps(&mut r1, &gps, &opts).unwrap();
315        assert!(r1.projection_center(6).unwrap().norm() < 1e-3);
316        // An explicit origin is independent of the line order.
317        let o = &gps[3];
318        let opts2 = ModelAlignerOptions { origin: EnuOrigin::Explicit { lat: o.lat, lon: o.lon, alt: o.alt }, ..opts.clone() };
319        let mut r2 = rec.clone();
320        align_to_gps(&mut r2, &gps, &opts2).unwrap();
321        let id_of_o = r2.images().find(|im| im.name == o.name).unwrap().image_id;
322        assert!(r2.projection_center(id_of_o).unwrap().norm() < 1e-3);
323        let _ = centers;
324    }
325
326    #[test]
327    fn robust_to_gps_outliers() {
328        // Of 50 cameras, 30% are contaminated by 20 m or more, the rest get σ=1 m noise, threshold 3 m.
329        let (rec, mut gps, centers, _) = scene(17, 13);
330        let mut rng = cumulus3d_core::ransac::make_rng(Some(5));
331        let enu = EnuFrame::new(gps[0].lat, gps[0].lon, gps[0].alt);
332        let c0 = centers[0];
333        let n = 50;
334        let gps_trunc: Vec<GpsRecord> = gps.drain(..n).collect();
335        let mut out = vec![false; n];
336        let mut noisy = Vec::new();
337        for (i, g) in gps_trunc.iter().enumerate() {
338            let truth = centers[i] - c0;
339            let e = if i % 10 < 3 && i != 0 {
340                out[i] = true;
341                truth + rand_vec(&mut rng, 1.0).normalize() * rng.random_range(20.0..60.0)
342            } else if i == 0 {
343                truth
344            } else {
345                truth + Vec3::new(gauss(&mut rng), gauss(&mut rng), gauss(&mut rng))
346            };
347            let (lat, lon, alt) = enu.enu_to_lla(&e);
348            noisy.push(GpsRecord { name: g.name.clone(), lat, lon, alt });
349        }
350        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(3), ..Default::default() }, ..Default::default() };
351        let mut r = rec.clone();
352        let a = align_to_gps(&mut r, &noisy, &opts).unwrap();
353        for (i, (id, _, _)) in a.common.iter().enumerate() {
354            let k = (*id - 1) as usize;
355            if out[k] {
356                assert!(!a.inlier_mask[i], "오염 {k} 이 인라이어");
357            }
358        }
359        let mut errs: Vec<f64> =
360            (0..n).filter(|k| !out[*k]).map(|k| (r.projection_center(k as u32 + 1).unwrap() - (centers[k] - c0)).norm()).collect();
361        let med = median(&mut errs);
362        assert!(med < 1.5, "median {med}");
363    }
364
365    #[test]
366    fn failure_cases_return_err_and_leave_model() {
367        let (mut rec, gps, _, _) = scene(4, 14);
368        let orig = rec.projection_center(1).unwrap();
369        // 2 common images.
370        let mut few = gps[..2].to_vec();
371        few.push(GpsRecord { name: "nope.jpg".into(), ..gps[2].clone() });
372        assert!(matches!(align_to_gps(&mut rec, &few, &Default::default()), Err(AlignError::TooFewCommonImages { found: 2, .. })));
373        assert!(matches!(align_to_gps(&mut rec, &gps[..2], &Default::default()), Err(AlignError::TooFewReferences { .. })));
374        let bad = ModelAlignerOptions { max_error: 0.0, ..Default::default() };
375        assert!(matches!(align_to_gps(&mut rec, &gps, &bad), Err(AlignError::BadMaxError(_))));
376        assert_eq!(rec.projection_center(1).unwrap(), orig);
377    }
378}