1use crate::umeyama::umeyama;
40use cumulus3d_core::linalg::svd3;
41use cumulus3d_core::{Mat3, Quat, Reconstruction, Sim3, Vec3};
42
43pub const COLLINEAR_RATIO: f64 = 0.05;
45
46#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum GravityPrior {
49 Ground {
51 inliers: usize,
53 },
54 ViewDirection,
56 None,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct GravityResolution {
63 pub ratio: f64,
65 pub prior: GravityPrior,
67}
68
69pub fn collinearity_ratio(pts: &[Vec3]) -> f64 {
71 line_axis(pts).map_or(1.0, |(_, r, _)| r)
72}
73
74pub fn degenerate_ratio(pts: &[Vec3]) -> Option<f64> {
76 let r = collinearity_ratio(pts);
77 (r < COLLINEAR_RATIO).then_some(r)
78}
79
80fn 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
98pub 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
111pub 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
124pub 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); }
131 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 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
162fn 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
178fn 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 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 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 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); 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 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)); 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)); }
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 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 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 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 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}