Skip to main content

cumulus3d_align/
model_aligner.rs

1//! GPS ENU 정렬: 등록 영상 투영 중심을 GPS 기준점의 ENU 좌표에 강건 Sim3 로 맞춘다.
2//!
3//! 실패하면 `Err` 를 돌려주고 모델을 건드리지 않는다(빈 출력으로 조용히 진행하지 않음).
4
5use crate::error::{AlignError, Result};
6use crate::geodesy::EnuFrame;
7use crate::umeyama::{estimate_sim3_ransac, median, RankCheck};
8use cumulus3d_core::io::{read_gps_file, GpsRecord};
9use cumulus3d_core::ransac::RansacParams;
10use cumulus3d_core::{ImageId, Reconstruction, Sim3, Vec3};
11use std::collections::{HashMap, HashSet};
12use std::path::Path;
13
14/// ENU 원점 선택.
15#[derive(Clone, Copy, Debug, PartialEq, Default)]
16pub enum EnuOrigin {
17    /// 기본 동작: GPS 목록의 첫 줄.
18    #[default]
19    FirstRecord,
20    /// 세션 전체 고정 원점(위도°, 경도°, 고도 m). 구역마다 원점이 바뀌는 문제를 피한다.
21    Explicit {
22        /// 위도(°).
23        lat: f64,
24        /// 경도(°).
25        lon: f64,
26        /// 타원체고(m).
27        alt: f64,
28    },
29}
30
31/// model_aligner 옵션. 기본값 = 스크립트 호출(`--alignment_max_error 3`, min_common_images 3).
32#[derive(Clone, Debug, PartialEq)]
33pub struct ModelAlignerOptions {
34    /// 인라이어 거리 임계 (m) (변환된 카메라 중심 ↔ GPS ENU 거리 ≤ 3 m).
35    pub max_error: f64,
36    /// 정렬에 필요한 최소 공통 영상 수.
37    pub min_common_images: usize,
38    /// ENU 원점 선택.
39    pub origin: EnuOrigin,
40    /// 신뢰도·반복 수·시드. `max_error` 필드는 무시되고 위 값이 쓰인다.
41    pub ransac: RansacParams,
42    /// 최소 표본 퇴화 판정 방식.
43    pub rank_check: RankCheck,
44}
45
46impl Default for ModelAlignerOptions {
47    fn default() -> Self {
48        Self {
49            max_error: 3.0,
50            min_common_images: 3,
51            origin: EnuOrigin::FirstRecord,
52            ransac: RansacParams::default(),
53            rank_check: RankCheck::Uncentered,
54        }
55    }
56}
57
58/// 정렬 결과.
59#[derive(Clone, Debug)]
60pub struct GpsAlignment {
61    /// model_to_enu.
62    pub sim3: Sim3,
63    /// 사용한 ENU 좌표계(원점 포함).
64    pub enu: EnuFrame,
65    /// 공통 영상(파일 순서): (영상 id, 이름, ENU 위치).
66    pub common: Vec<(ImageId, String, Vec3)>,
67    /// `common` 과 같은 순서의 인라이어 여부.
68    pub inlier_mask: Vec<bool>,
69    /// 인라이어 수.
70    pub num_inliers: usize,
71    /// RANSAC 시행 수.
72    pub num_trials: usize,
73    /// 정렬 후 ‖C' − g‖ (파일의 각 이름 중 모델에 있고 자세가 있는 영상, 필터링 없음).
74    pub errors: Vec<(String, f64)>,
75    /// 정렬 오차 평균 (m).
76    pub mean_error: f64,
77    /// 정렬 오차 중앙값 (m).
78    pub median_error: f64,
79}
80
81/// GPS 목록 → (이름, ENU). 원점이 `FirstRecord` 인데 목록이 비면 오류.
82pub fn gps_to_enu(records: &[GpsRecord], origin: EnuOrigin) -> Result<(EnuFrame, Vec<(String, Vec3)>)> {
83    let frame = match origin {
84        EnuOrigin::FirstRecord => {
85            let r = records.first().ok_or(AlignError::TooFewReferences { found: 0, required: 1 })?;
86            EnuFrame::new(r.lat, r.lon, r.alt)
87        }
88        EnuOrigin::Explicit { lat, lon, alt } => EnuFrame::new(lat, lon, alt),
89    };
90    let v = records.iter().map(|r| (r.name.clone(), frame.lla_to_enu(r.lat, r.lon, r.alt))).collect();
91    Ok((frame, v))
92}
93
94/// 정렬 Sim3 만 추정(모델 불변).
95pub fn estimate_gps_alignment(rec: &Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
96    if opts.max_error.is_nan() || opts.max_error <= 0.0 {
97        return Err(AlignError::BadMaxError(opts.max_error));
98    }
99    // 2. 기준 위치 < 3 → 실패.
100    if gps.len() < 3 {
101        return Err(AlignError::TooFewReferences { found: gps.len(), required: 3 });
102    }
103    let (enu, refs) = gps_to_enu(gps, opts.origin)?;
104
105    // 4. 공통 영상(파일 순서, 이름 일치, 자세 있음, 중복 제외).
106    let by_name: HashMap<&str, ImageId> = rec.images().map(|im| (im.name.as_str(), im.image_id)).collect();
107    let mut seen = HashSet::new();
108    let mut common = Vec::new();
109    let (mut src, mut dst) = (Vec::new(), Vec::new());
110    for (name, g) in &refs {
111        let Some(&id) = by_name.get(name.as_str()) else { continue };
112        let Some(c) = rec.projection_center(id) else { continue };
113        if !seen.insert(id) {
114            continue;
115        }
116        common.push((id, name.clone(), *g));
117        src.push(c);
118        dst.push(*g);
119    }
120    // 5.
121    let required = opts.min_common_images.max(3);
122    if common.len() < required {
123        return Err(AlignError::TooFewCommonImages { found: common.len(), required });
124    }
125    // 6.
126    let ropts = RansacParams { max_error: opts.max_error, ..opts.ransac.clone() };
127    let rep = estimate_sim3_ransac(&src, &dst, &ropts, opts.rank_check);
128    let num_inliers = rep.support.num_inliers;
129    let sim3 = match (rep.success, rep.model) {
130        (true, Some(m)) if num_inliers >= 3 => m,
131        _ => return Err(AlignError::RansacFailed { inliers: num_inliers }),
132    };
133
134    // 8. 정렬 오차(파일의 모든 이름, 필터 없음; 중복 줄도 각각 센다).
135    let mut errors = Vec::new();
136    for (name, g) in &refs {
137        let Some(&id) = by_name.get(name.as_str()) else { continue };
138        let Some(c) = rec.projection_center(id) else { continue };
139        errors.push((name.clone(), (sim3.transform_point(&c) - g).norm()));
140    }
141    let mut ev: Vec<f64> = errors.iter().map(|e| e.1).collect();
142    let mean_error = if ev.is_empty() { f64::NAN } else { ev.iter().sum::<f64>() / ev.len() as f64 };
143    // 설계 결정: 중앙값의 짝수 개 처리. 가운데 둘의 평균을 쓴다.
144    let median_error = median(&mut ev);
145
146    Ok(GpsAlignment {
147        sim3,
148        enu,
149        common,
150        inlier_mask: rep.inlier_mask,
151        num_inliers,
152        num_trials: rep.num_trials,
153        errors,
154        mean_error,
155        median_error,
156    })
157}
158
159/// 추정 후 성공하면 모델(자세·점)에 적용. 실패하면 모델을 바꾸지 않고 `Err`.
160pub fn align_to_gps(rec: &mut Reconstruction, gps: &[GpsRecord], opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
161    let a = estimate_gps_alignment(rec, gps, opts)?;
162    rec.transform(&a.sim3);
163    Ok(a)
164}
165
166/// GPS 파일 경로판.
167pub fn align_to_gps_file(rec: &mut Reconstruction, path: impl AsRef<Path>, opts: &ModelAlignerOptions) -> Result<GpsAlignment> {
168    let gps = read_gps_file(path)?;
169    align_to_gps(rec, &gps, opts)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::geodesy::EnuFrame;
176    use crate::umeyama::tests::{gauss, rand_vec, random_sim3};
177    use rand::RngExt;
178    use cumulus3d_core::{Camera, CameraModelKind, Image, Quat, Rigid3, TrackEntry};
179
180    /// ENU 궤적(드론 3대 × 위치)을 만들고, 임의 Sim3 로 "모델 좌표"로 옮긴 재구성 + GPS 목록.
181    fn scene(num_pos: usize, seed: u64) -> (Reconstruction, Vec<GpsRecord>, Vec<Vec3>, Sim3) {
182        let mut rng = cumulus3d_core::ransac::make_rng(Some(seed));
183        let enu = EnuFrame::new(37.5, 127.0, 50.0);
184        let enu_to_model = random_sim3(&mut rng);
185        let mut rec = Reconstruction::new();
186        let cam = Camera::new(1, CameraModelKind::Pinhole, 1000, 800, vec![800.0, 800.0, 500.0, 400.0]).unwrap();
187        rec.add_camera_own_rig(cam).unwrap();
188        let mut gps = Vec::new();
189        let mut centers = Vec::new();
190        let mut id = 1;
191        for p in 0..num_pos {
192            for (k, dx) in [-20.0, 0.0, 20.0].iter().enumerate() {
193                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);
194                let c_model = enu_to_model.transform_point(&c_enu);
195                // 아래(−U)를 보는 카메라 + 작은 무작위 회전.
196                let down = Quat::from_axis_angle(&Vec3::x(), std::f64::consts::PI);
197                let small = Quat::from_axis_angle(&rand_vec(&mut rng, 1.0).normalize(), rng.random_range(-0.2..0.2));
198                // 모델 좌표계 회전도 반영해야 아래를 본다: R_cam = small·down·R_mᵀ.
199                let rot = small * down * enu_to_model.rotation.conjugate();
200                let r = rot.to_rotation_matrix();
201                let pose = Rigid3::new(rot, -(r * c_model));
202                let pts: Vec<_> = (0..20).map(|i| cumulus3d_core::Vec2::new(10.0 + i as f64, 20.0)).collect();
203                let name = format!("cam{k}/img_{p:04}.jpg");
204                rec.add_image_own_frame(Image::new(id, &name, 1, pts), Some(pose)).unwrap();
205                rec.register_image(id).unwrap();
206                let (lat, lon, alt) = enu.enu_to_lla(&c_enu);
207                gps.push(GpsRecord { name, lat, lon, alt });
208                centers.push(c_enu);
209                id += 1;
210            }
211        }
212        // 관측 몇 개(재투영 불변 확인용).
213        for i in 0..10u32 {
214            let x = enu_to_model.transform_point(&Vec3::new(10.0 + i as f64, 5.0, 0.0));
215            let pid = rec.add_point3d(x, vec![], [0, 0, 0]).unwrap();
216            for img in 1..=6u32 {
217                let pose = rec.world_to_cam(img).unwrap();
218                let cam = rec.camera(1).unwrap();
219                if let Some(_xy) = cam.cam_to_img(&pose.transform_point(&x)) {
220                    rec.add_observation(pid, TrackEntry::new(img, i)).unwrap();
221                }
222            }
223        }
224        (rec, gps, centers, enu_to_model)
225    }
226
227    /// 모든 관측의 투영 픽셀.
228    fn projections(rec: &Reconstruction) -> Vec<cumulus3d_core::Vec2> {
229        let mut out = Vec::new();
230        for id in rec.point3d_ids() {
231            let p = rec.point3d(id).unwrap();
232            for e in &p.track {
233                let pose = rec.world_to_cam(e.image_id).unwrap();
234                let cam = rec.camera(1).unwrap();
235                out.push(cam.cam_to_img(&pose.transform_point(&p.xyz)).unwrap());
236            }
237        }
238        out
239    }
240
241    #[test]
242    fn aligns_synthetic_model() {
243        let (mut rec, gps, centers, enu_to_model) = scene(10, 11);
244        let before = projections(&rec);
245        assert!(before.len() > 10);
246        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(1), ..Default::default() }, ..Default::default() };
247        let a = align_to_gps(&mut rec, &gps, &opts).unwrap();
248        assert_eq!(a.num_inliers, 30);
249        // 원점 = 첫 줄 위치이므로, 첫 영상 중심은 ENU (0,0,0) 이고 나머지는 원래 ENU 에서 c0 만큼 이동.
250        let c0 = centers[0];
251        for (i, c) in centers.iter().enumerate() {
252            let got = rec.projection_center(i as u32 + 1).unwrap();
253            assert!((got - (c - c0)).norm() < 1e-3, "{i}: {got:?} vs {:?}", c - c0);
254        }
255        assert!(a.median_error < 1e-3 && a.mean_error < 1e-3);
256        let expect = enu_to_model.inverse();
257        assert!((a.sim3.scale - expect.scale).abs() / expect.scale < 1e-6);
258        // Sim3 적용 후 재투영 오차 불변.
259        let after = projections(&rec);
260        assert_eq!(before.len(), after.len());
261        for (b, a) in before.iter().zip(&after) {
262            assert!((b - a).norm() < 1e-8, "{b:?} vs {a:?}");
263        }
264        // C' = s R C + t.
265        let c_before = enu_to_model.transform_point(&centers[4]);
266        assert!((a.sim3.transform_point(&c_before) - rec.projection_center(5).unwrap()).norm() < 1e-9);
267    }
268
269    #[test]
270    fn explicit_origin_and_order_rule() {
271        let (rec, mut gps, centers, _) = scene(6, 12);
272        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(2), ..Default::default() }, ..Default::default() };
273        // 줄 순서를 바꾸면 원점이 바뀐다.
274        gps.swap(0, 5);
275        let mut r1 = rec.clone();
276        align_to_gps(&mut r1, &gps, &opts).unwrap();
277        assert!(r1.projection_center(6).unwrap().norm() < 1e-3);
278        // 명시 원점은 줄 순서와 무관.
279        let o = &gps[3];
280        let opts2 = ModelAlignerOptions { origin: EnuOrigin::Explicit { lat: o.lat, lon: o.lon, alt: o.alt }, ..opts.clone() };
281        let mut r2 = rec.clone();
282        align_to_gps(&mut r2, &gps, &opts2).unwrap();
283        let id_of_o = r2.images().find(|im| im.name == o.name).unwrap().image_id;
284        assert!(r2.projection_center(id_of_o).unwrap().norm() < 1e-3);
285        let _ = centers;
286    }
287
288    #[test]
289    fn robust_to_gps_outliers() {
290        // 50 카메라 중 30% 를 20 m 이상 오염, 나머지 σ=1 m 잡음, 임계 3 m.
291        let (rec, mut gps, centers, _) = scene(17, 13);
292        let mut rng = cumulus3d_core::ransac::make_rng(Some(5));
293        let enu = EnuFrame::new(gps[0].lat, gps[0].lon, gps[0].alt);
294        let c0 = centers[0];
295        let n = 50;
296        let gps_trunc: Vec<GpsRecord> = gps.drain(..n).collect();
297        let mut out = vec![false; n];
298        let mut noisy = Vec::new();
299        for (i, g) in gps_trunc.iter().enumerate() {
300            let truth = centers[i] - c0;
301            let e = if i % 10 < 3 && i != 0 {
302                out[i] = true;
303                truth + rand_vec(&mut rng, 1.0).normalize() * rng.random_range(20.0..60.0)
304            } else if i == 0 {
305                truth
306            } else {
307                truth + Vec3::new(gauss(&mut rng), gauss(&mut rng), gauss(&mut rng))
308            };
309            let (lat, lon, alt) = enu.enu_to_lla(&e);
310            noisy.push(GpsRecord { name: g.name.clone(), lat, lon, alt });
311        }
312        let opts = ModelAlignerOptions { ransac: RansacParams { random_seed: Some(3), ..Default::default() }, ..Default::default() };
313        let mut r = rec.clone();
314        let a = align_to_gps(&mut r, &noisy, &opts).unwrap();
315        for (i, (id, _, _)) in a.common.iter().enumerate() {
316            let k = (*id - 1) as usize;
317            if out[k] {
318                assert!(!a.inlier_mask[i], "오염 {k} 이 인라이어");
319            }
320        }
321        let mut errs: Vec<f64> = (0..n)
322            .filter(|k| !out[*k])
323            .map(|k| (r.projection_center(k as u32 + 1).unwrap() - (centers[k] - c0)).norm())
324            .collect();
325        let med = median(&mut errs);
326        assert!(med < 1.5, "median {med}");
327    }
328
329    #[test]
330    fn failure_cases_return_err_and_leave_model() {
331        let (mut rec, gps, _, _) = scene(4, 14);
332        let orig = rec.projection_center(1).unwrap();
333        // 공통 영상 2개.
334        let mut few = gps[..2].to_vec();
335        few.push(GpsRecord { name: "nope.jpg".into(), ..gps[2].clone() });
336        assert!(matches!(align_to_gps(&mut rec, &few, &Default::default()), Err(AlignError::TooFewCommonImages { found: 2, .. })));
337        assert!(matches!(align_to_gps(&mut rec, &gps[..2], &Default::default()), Err(AlignError::TooFewReferences { .. })));
338        let bad = ModelAlignerOptions { max_error: 0.0, ..Default::default() };
339        assert!(matches!(align_to_gps(&mut rec, &gps, &bad), Err(AlignError::BadMaxError(_))));
340        assert_eq!(rec.projection_center(1).unwrap(), orig);
341    }
342}