1use crate::error::{AlignError, Result};
38use crate::geodesy::EnuFrame;
39use crate::umeyama::{estimate_sim3_ransac, median, RankCheck};
40use cumulus3d_core::io::{read_gps_file, GpsRecord};
41use cumulus3d_core::ransac::RansacParams;
42use cumulus3d_core::{ImageId, Reconstruction, Sim3, Vec3};
43use std::collections::{HashMap, HashSet};
44use std::path::Path;
45
46#[derive(Clone, Copy, Debug, PartialEq, Default)]
48pub enum EnuOrigin {
49 #[default]
51 FirstRecord,
52 Explicit {
54 lat: f64,
56 lon: f64,
58 alt: f64,
60 },
61}
62
63#[derive(Clone, Debug, PartialEq)]
65pub struct ModelAlignerOptions {
66 pub max_error: f64,
68 pub min_common_images: usize,
70 pub origin: EnuOrigin,
72 pub ransac: RansacParams,
74 pub rank_check: RankCheck,
76}
77
78impl Default for ModelAlignerOptions {
79 fn default() -> Self {
80 Self {
81 max_error: 3.0,
82 min_common_images: 3,
83 origin: EnuOrigin::FirstRecord,
84 ransac: RansacParams::default(),
85 rank_check: RankCheck::Uncentered,
86 }
87 }
88}
89
90#[derive(Clone, Debug)]
92pub struct GpsAlignment {
93 pub sim3: Sim3,
95 pub enu: EnuFrame,
97 pub common: Vec<(ImageId, String, Vec3)>,
99 pub inlier_mask: Vec<bool>,
101 pub num_inliers: usize,
103 pub num_trials: usize,
105 pub errors: Vec<(String, f64)>,
107 pub mean_error: f64,
109 pub median_error: f64,
111}
112
113pub fn gps_to_enu(records: &[GpsRecord], origin: EnuOrigin) -> Result<(EnuFrame, Vec<(String, Vec3)>)> {
115 let frame = match origin {
116 EnuOrigin::FirstRecord => {
117 let r = records.first().ok_or(AlignError::TooFewReferences { found: 0, required: 1 })?;
118 EnuFrame::new(r.lat, r.lon, r.alt)
119 }
120 EnuOrigin::Explicit { lat, lon, alt } => EnuFrame::new(lat, lon, alt),
121 };
122 let v = records.iter().map(|r| (r.name.clone(), frame.lla_to_enu(r.lat, r.lon, r.alt))).collect();
123 Ok((frame, v))
124}
125
126pub fn estimate_gps_alignment(rec: &Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
128 if opts.max_error.is_nan() || opts.max_error <= 0.0 {
129 return Err(AlignError::BadMaxError(opts.max_error));
130 }
131 if gps.len() < 3 {
133 return Err(AlignError::TooFewReferences { found: gps.len(), required: 3 });
134 }
135 let (enu, refs) = gps_to_enu(gps, opts.origin)?;
136
137 let by_name: HashMap<&str, ImageId> = rec.images().map(|im| (im.name.as_str(), im.image_id)).collect();
139 let mut seen = HashSet::new();
140 let mut common = Vec::new();
141 let (mut src, mut dst) = (Vec::new(), Vec::new());
142 for (name, g) in &refs {
143 let Some(&id) = by_name.get(name.as_str()) else { continue };
144 let Some(c) = rec.projection_center(id) else { continue };
145 if !seen.insert(id) {
146 continue;
147 }
148 common.push((id, name.clone(), *g));
149 src.push(c);
150 dst.push(*g);
151 }
152 let required = opts.min_common_images.max(3);
154 if common.len() < required {
155 return Err(AlignError::TooFewCommonImages { found: common.len(), required });
156 }
157 let ropts = RansacParams { max_error: opts.max_error, ..opts.ransac.clone() };
159 let rep = estimate_sim3_ransac(&src, &dst, &ropts, opts.rank_check);
160 let num_inliers = rep.support.num_inliers;
161 let sim3 = match (rep.success, rep.model) {
162 (true, Some(m)) if num_inliers >= 3 => m,
163 _ => return Err(AlignError::RansacFailed { inliers: num_inliers }),
164 };
165
166 let mut errors = Vec::new();
168 for (name, g) in &refs {
169 let Some(&id) = by_name.get(name.as_str()) else { continue };
170 let Some(c) = rec.projection_center(id) else { continue };
171 errors.push((name.clone(), (sim3.transform_point(&c) - g).norm()));
172 }
173 let mut ev: Vec<f64> = errors.iter().map(|e| e.1).collect();
174 let mean_error = if ev.is_empty() { f64::NAN } else { ev.iter().sum::<f64>() / ev.len() as f64 };
175 let median_error = median(&mut ev);
177
178 Ok(GpsAlignment {
179 sim3,
180 enu,
181 common,
182 inlier_mask: rep.inlier_mask,
183 num_inliers,
184 num_trials: rep.num_trials,
185 errors,
186 mean_error,
187 median_error,
188 })
189}
190
191pub fn align_to_gps(rec: &mut Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
193 let a = estimate_gps_alignment(rec, gps, opts)?;
194 rec.transform(&a.sim3);
195 Ok(a)
196}
197
198pub fn align_to_gps_file(rec: &mut Reconstruction, path: impl AsRef<Path>, opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
200 let gps = read_gps_file(path)?;
201 align_to_gps(rec, &gps, opts)
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::geodesy::EnuFrame;
208 use crate::umeyama::tests::{gauss, rand_vec, random_sim3};
209 use cumulus3d_core::{Camera, CameraModelKind, Image, Quat, Rigid3, TrackEntry};
210 use rand::RngExt;
211
212 fn scene(num_pos: usize, seed: u64) -> (Reconstruction, Vec<GpsRecord>, Vec<Vec3>, Sim3) {
215 let mut rng = cumulus3d_core::ransac::make_rng(Some(seed));
216 let enu = EnuFrame::new(37.5, 127.0, 50.0);
217 let enu_to_model = random_sim3(&mut rng);
218 let mut rec = Reconstruction::new();
219 let cam = Camera::new(1, CameraModelKind::Pinhole, 1000, 800, vec![800.0, 800.0, 500.0, 400.0]).unwrap();
220 rec.add_camera_own_rig(cam).unwrap();
221 let mut gps = Vec::new();
222 let mut centers = Vec::new();
223 let mut id = 1;
224 for p in 0..num_pos {
225 for (k, dx) in [-20.0, 0.0, 20.0].iter().enumerate() {
226 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);
227 let c_model = enu_to_model.transform_point(&c_enu);
228 let down = Quat::from_axis_angle(&Vec3::x(), std::f64::consts::PI);
230 let small = Quat::from_axis_angle(&rand_vec(&mut rng, 1.0).normalize(), rng.random_range(-0.2..0.2));
231 let rot = small * down * enu_to_model.rotation.conjugate();
233 let r = rot.to_rotation_matrix();
234 let pose = Rigid3::new(rot, -(r * c_model));
235 let pts: Vec<_> = (0..20).map(|i| cumulus3d_core::Vec2::new(10.0 + i as f64, 20.0)).collect();
236 let name = format!("cam{k}/img_{p:04}.jpg");
237 rec.add_image_own_frame(Image::new(id, &name, 1, pts), Some(pose)).unwrap();
238 rec.register_image(id).unwrap();
239 let (lat, lon, alt) = enu.enu_to_lla(&c_enu);
240 gps.push(GpsRecord { name, lat, lon, alt });
241 centers.push(c_enu);
242 id += 1;
243 }
244 }
245 for i in 0..10u32 {
247 let x = enu_to_model.transform_point(&Vec3::new(10.0 + i as f64, 5.0, 0.0));
248 let pid = rec.add_point3d(x, vec![], [0, 0, 0]).unwrap();
249 for img in 1..=6u32 {
250 let pose = rec.world_to_cam(img).unwrap();
251 let cam = rec.camera(1).unwrap();
252 if let Some(_xy) = cam.cam_to_img(&pose.transform_point(&x)) {
253 rec.add_observation(pid, TrackEntry::new(img, i)).unwrap();
254 }
255 }
256 }
257 (rec, gps, centers, enu_to_model)
258 }
259
260 fn projections(rec: &Reconstruction) -> Vec<cumulus3d_core::Vec2> {
262 let mut out = Vec::new();
263 for id in rec.point3d_ids() {
264 let p = rec.point3d(id).unwrap();
265 for e in &p.track {
266 let pose = rec.world_to_cam(e.image_id).unwrap();
267 let cam = rec.camera(1).unwrap();
268 out.push(cam.cam_to_img(&pose.transform_point(&p.xyz)).unwrap());
269 }
270 }
271 out
272 }
273
274 #[test]
275 fn aligns_synthetic_model() {
276 let (mut rec, gps, centers, enu_to_model) = scene(10, 11);
277 let before = projections(&rec);
278 assert!(before.len() > 10);
279 let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(1), ..Default::default() }, ..Default::default() };
280 let a = align_to_gps(&mut rec, &gps, &opts).unwrap();
281 assert_eq!(a.num_inliers, 30);
282 let c0 = centers[0];
284 for (i, c) in centers.iter().enumerate() {
285 let got = rec.projection_center(i as u32 + 1).unwrap();
286 assert!((got - (c - c0)).norm() < 1e-3, "{i}: {got:?} vs {:?}", c - c0);
287 }
288 assert!(a.median_error < 1e-3 && a.mean_error < 1e-3);
289 let expect = enu_to_model.inverse();
290 assert!((a.sim3.scale - expect.scale).abs() / expect.scale < 1e-6);
291 let after = projections(&rec);
293 assert_eq!(before.len(), after.len());
294 for (b, a) in before.iter().zip(&after) {
295 assert!((b - a).norm() < 1e-8, "{b:?} vs {a:?}");
296 }
297 let c_before = enu_to_model.transform_point(¢ers[4]);
299 assert!((a.sim3.transform_point(&c_before) - rec.projection_center(5).unwrap()).norm() < 1e-9);
300 }
301
302 #[test]
303 fn explicit_origin_and_order_rule() {
304 let (rec, mut gps, centers, _) = scene(6, 12);
305 let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(2), ..Default::default() }, ..Default::default() };
306 gps.swap(0, 5);
308 let mut r1 = rec.clone();
309 align_to_gps(&mut r1, &gps, &opts).unwrap();
310 assert!(r1.projection_center(6).unwrap().norm() < 1e-3);
311 let o = &gps[3];
313 let opts2 = ModelAlignerOptions { origin: EnuOrigin::Explicit { lat: o.lat, lon: o.lon, alt: o.alt }, ..opts.clone() };
314 let mut r2 = rec.clone();
315 align_to_gps(&mut r2, &gps, &opts2).unwrap();
316 let id_of_o = r2.images().find(|im| im.name == o.name).unwrap().image_id;
317 assert!(r2.projection_center(id_of_o).unwrap().norm() < 1e-3);
318 let _ = centers;
319 }
320
321 #[test]
322 fn robust_to_gps_outliers() {
323 let (rec, mut gps, centers, _) = scene(17, 13);
325 let mut rng = cumulus3d_core::ransac::make_rng(Some(5));
326 let enu = EnuFrame::new(gps[0].lat, gps[0].lon, gps[0].alt);
327 let c0 = centers[0];
328 let n = 50;
329 let gps_trunc: Vec<GpsRecord> = gps.drain(..n).collect();
330 let mut out = vec![false; n];
331 let mut noisy = Vec::new();
332 for (i, g) in gps_trunc.iter().enumerate() {
333 let truth = centers[i] - c0;
334 let e = if i % 10 < 3 && i != 0 {
335 out[i] = true;
336 truth + rand_vec(&mut rng, 1.0).normalize() * rng.random_range(20.0..60.0)
337 } else if i == 0 {
338 truth
339 } else {
340 truth + Vec3::new(gauss(&mut rng), gauss(&mut rng), gauss(&mut rng))
341 };
342 let (lat, lon, alt) = enu.enu_to_lla(&e);
343 noisy.push(GpsRecord { name: g.name.clone(), lat, lon, alt });
344 }
345 let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(3), ..Default::default() }, ..Default::default() };
346 let mut r = rec.clone();
347 let a = align_to_gps(&mut r, &noisy, &opts).unwrap();
348 for (i, (id, _, _)) in a.common.iter().enumerate() {
349 let k = (*id - 1) as usize;
350 if out[k] {
351 assert!(!a.inlier_mask[i], "오염 {k} 이 인라이어");
352 }
353 }
354 let mut errs: Vec<f64> =
355 (0..n).filter(|k| !out[*k]).map(|k| (r.projection_center(k as u32 + 1).unwrap() - (centers[k] - c0)).norm()).collect();
356 let med = median(&mut errs);
357 assert!(med < 1.5, "median {med}");
358 }
359
360 #[test]
361 fn failure_cases_return_err_and_leave_model() {
362 let (mut rec, gps, _, _) = scene(4, 14);
363 let orig = rec.projection_center(1).unwrap();
364 let mut few = gps[..2].to_vec();
366 few.push(GpsRecord { name: "nope.jpg".into(), ..gps[2].clone() });
367 assert!(matches!(align_to_gps(&mut rec, &few, &Default::default()), Err(AlignError::TooFewCommonImages { found: 2, .. })));
368 assert!(matches!(align_to_gps(&mut rec, &gps[..2], &Default::default()), Err(AlignError::TooFewReferences { .. })));
369 let bad = ModelAlignerOptions { max_error: 0.0, ..Default::default() };
370 assert!(matches!(align_to_gps(&mut rec, &gps, &bad), Err(AlignError::BadMaxError(_))));
371 assert_eq!(rec.projection_center(1).unwrap(), orig);
372 }
373}