Skip to main content

cumulus3d_align/
shared.rs

1//! 두 재구성 간 공유 3D 점 대응(후처리).
2//!
3//! 같은 이름의 영상에서 같은 2D 점 인덱스가 A 의 3D 점 i, B 의 3D 점 j 를 가리키면 (i, j) 쌍.
4//! 초벌은 정밀본과 같은 특징을 공유하므로(BA 전 사본) 인덱스가 그대로 대응한다.
5
6use crate::error::{AlignError, Result};
7use crate::umeyama::{robust_umeyama, RobustUmeyamaOptions, RobustUmeyamaResult};
8use cumulus3d_core::{Point3DId, Reconstruction, Vec3};
9use std::collections::HashMap;
10use std::ops::Range;
11use std::sync::Arc;
12
13/// 영상 이름 필터 함수.
14pub type NameFilter = Arc<dyn Fn(&str) -> bool + Send + Sync>;
15
16/// 대응 수집 옵션.
17#[derive(Clone, Default)]
18pub struct SharedPointOptions {
19    /// 위치 인덱스(이름의 마지막 숫자열, [`position_index_from_name`]) 범위. 범위 밖 영상은 건너뜀.
20    pub position_range: Option<Range<u32>>,
21    /// 추가 이름 필터(true = 사용).
22    pub name_filter: Option<NameFilter>,
23}
24
25impl std::fmt::Debug for SharedPointOptions {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("SharedPointOptions")
28            .field("position_range", &self.position_range)
29            .field("name_filter", &self.name_filter.is_some())
30            .finish()
31    }
32}
33
34impl SharedPointOptions {
35    fn accepts(&self, name: &str) -> bool {
36        if let Some(r) = &self.position_range {
37            match position_index_from_name(name) {
38                Some(p) if r.contains(&p) => {}
39                _ => return false,
40            }
41        }
42        self.name_filter.as_ref().is_none_or(|f| f(name))
43    }
44}
45
46/// 영상 이름에서 위치 인덱스: 파일 줄기(stem)의 마지막 숫자열. 예 `camF/camF_0012.jpg` → 12.
47pub fn position_index_from_name(name: &str) -> Option<u32> {
48    let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
49    let stem = file.rsplit_once('.').map_or(file, |(s, _)| s);
50    let end = stem.rfind(|c: char| c.is_ascii_digit())? + 1;
51    let start = stem[..end].rfind(|c: char| !c.is_ascii_digit()).map_or(0, |i| i + 1);
52    stem[start..end].parse().ok()
53}
54
55/// 공유 3D 점 쌍 (A 점 id, B 점 id). 중복 제거, 정렬된 결정적 순서.
56pub fn shared_point_correspondences(a: &Reconstruction, b: &Reconstruction, opts: &SharedPointOptions) -> Vec<(Point3DId, Point3DId)> {
57    let b_by_name: HashMap<&str, _> = b.images().map(|im| (im.name.as_str(), im)).collect();
58    let mut pairs = Vec::new();
59    for ia in a.images() {
60        if !opts.accepts(&ia.name) {
61            continue;
62        }
63        let Some(ib) = b_by_name.get(ia.name.as_str()) else { continue };
64        // 설계 결정: 두 영상의 2D 점 수가 다르면(다른 특징 추출) 공통 인덱스 구간만 본다.
65        for (pa, pb) in ia.points2d().iter().zip(ib.points2d()) {
66            if pa.has_point3d() && pb.has_point3d() {
67                pairs.push((pa.point3d_id, pb.point3d_id));
68            }
69        }
70    }
71    pairs.sort_unstable();
72    pairs.dedup();
73    pairs
74}
75
76/// 공유 점 대응 + 견고 Umeyama 로 `src_to_dst` Sim3 추정(초벌 → 정밀, 이전 정밀 → 새 정밀).
77pub fn align_reconstructions(
78    src: &Reconstruction,
79    dst: &Reconstruction,
80    opts: &SharedPointOptions,
81    robust: &RobustUmeyamaOptions,
82) -> Result<(RobustUmeyamaResult, Vec<(Point3DId, Point3DId)>)> {
83    let pairs = shared_point_correspondences(src, dst, opts);
84    if pairs.len() < 3 {
85        return Err(AlignError::TooFewCorrespondences { found: pairs.len(), required: 3 });
86    }
87    let (xs, ys): (Vec<Vec3>, Vec<Vec3>) = pairs
88        .iter()
89        .map(|(i, j)| (src.point3d(*i).expect("track id").xyz, dst.point3d(*j).expect("track id").xyz))
90        .unzip();
91    let r = robust_umeyama(&xs, &ys, robust).ok_or(AlignError::Degenerate)?;
92    Ok((r, pairs))
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::umeyama::tests::{random_sim3, sim_close};
99    use cumulus3d_core::{Camera, CameraModelKind, Image, Rigid3, TrackEntry, Vec2};
100
101    fn base(n_img: u32, n_pts: u32) -> Reconstruction {
102        let mut rec = Reconstruction::new();
103        rec.add_camera_own_rig(camera(1)).unwrap();
104        for i in 1..=n_img {
105            let pts: Vec<Vec2> = (0..n_pts).map(|k| Vec2::new(k as f64, 0.0)).collect();
106            rec.add_image_own_frame(Image::new(i, format!("camF/camF_{:04}.jpg", i - 1), 1, pts), Some(Rigid3::identity()))
107                .unwrap();
108            rec.register_image(i).unwrap();
109        }
110        rec
111    }
112
113    fn camera(id: u32) -> Camera {
114        let mut c = Camera::from_focal(CameraModelKind::Pinhole, 500.0, 640, 480);
115        c.camera_id = id;
116        c
117    }
118
119    #[test]
120    fn name_position_index() {
121        assert_eq!(position_index_from_name("camF/camF_0012.jpg"), Some(12));
122        assert_eq!(position_index_from_name("a7/x_3_0040.png"), Some(40));
123        assert_eq!(position_index_from_name("nodigits.jpg"), None);
124    }
125
126    #[test]
127    fn correspondences_and_alignment() {
128        // A: 점 k 가 영상 1·2 의 2D 인덱스 k 에 관측. B: 같은 관측을 다른 id(k+100), 다른 좌표계.
129        let truth = random_sim3(&mut cumulus3d_core::ransac::make_rng(Some(4)));
130        let (mut a, mut b) = (base(3, 30), base(3, 30));
131        for k in 0..30u32 {
132            let x = Vec3::new(k as f64, (k * k % 7) as f64, (k % 5) as f64 * 2.0);
133            let ta = vec![TrackEntry::new(1, k), TrackEntry::new(2, k)];
134            a.add_point3d_with_id(k as u64 + 1, cumulus3d_core::Point3D { xyz: x, color: [0; 3], error: -1.0, track: ta }).unwrap();
135            // B 는 영상 2·3 에서 관측, 영상 2 만 공유. 앞 25개만.
136            if k < 25 {
137                let tb = vec![TrackEntry::new(2, k), TrackEntry::new(3, k)];
138                let mut y = truth.transform_point(&x);
139                if k == 0 {
140                    y += Vec3::new(1000.0, 0.0, 0.0); // 이상치
141                }
142                b.add_point3d_with_id(k as u64 + 100, cumulus3d_core::Point3D { xyz: y, color: [0; 3], error: -1.0, track: tb })
143                    .unwrap();
144            }
145        }
146        let pairs = shared_point_correspondences(&a, &b, &Default::default());
147        assert_eq!(pairs.len(), 25);
148        assert!(pairs.iter().all(|(i, j)| *j == *i + 99));
149        // 위치 범위 필터: 영상 2 = 위치 1. 범위 [2,3) 이면 공유 없음.
150        let none = shared_point_correspondences(&a, &b, &SharedPointOptions { position_range: Some(2..3), ..Default::default() });
151        assert!(none.is_empty());
152        let one = shared_point_correspondences(&a, &b, &SharedPointOptions { position_range: Some(1..2), ..Default::default() });
153        assert_eq!(one.len(), 25);
154        let opts = RobustUmeyamaOptions { min_threshold: 1e-6, ..Default::default() };
155        let (r, _) = align_reconstructions(&a, &b, &Default::default(), &opts).unwrap();
156        sim_close(&r.sim3, &truth, 1e-8);
157        assert_eq!(r.num_inliers, 24);
158        assert!(!r.inlier_mask[0]);
159    }
160
161    #[test]
162    fn dedup_pairs() {
163        // 같은 점 쌍이 두 영상에서 반복 관측되면 한 번만.
164        let (mut a, mut b) = (base(2, 5), base(2, 5));
165        for k in 0..5u32 {
166            let t = vec![TrackEntry::new(1, k), TrackEntry::new(2, k)];
167            let p = cumulus3d_core::Point3D { xyz: Vec3::new(k as f64, 1.0, 2.0), color: [0; 3], error: -1.0, track: t };
168            a.add_point3d_with_id(k as u64 + 1, p.clone()).unwrap();
169            b.add_point3d_with_id(k as u64 + 1, p).unwrap();
170        }
171        assert_eq!(shared_point_correspondences(&a, &b, &Default::default()).len(), 5);
172    }
173}