Skip to main content

cumulus3d_core/
graph.rs

1/*
2 * graph.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//! Correspondence graph.
33//!
34//! Images and pairs can be added incrementally, so the whole graph need not be rebuilt when a new image arrives.
35//! Per-2D-point correspondences are kept in a small Vec (flattening omitted, semantics identical).
36
37use crate::features::{FeatureMatch, TwoViewGeometry, TwoViewGeometryConfig};
38use crate::ids::*;
39use crate::store::FeatureStore;
40use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
41
42/// One correspondence: (image id, 2D point index).
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub struct Correspondence {
45    /// Image id.
46    pub image_id: ImageId,
47    /// 2D point index.
48    pub point2d_idx: Point2DIdx,
49}
50
51impl Correspondence {
52    /// Creates from (image, 2D point).
53    pub fn new(image_id: ImageId, point2d_idx: Point2DIdx) -> Self {
54        Self { image_id, point2d_idx }
55    }
56}
57
58#[derive(Clone, Debug, Default)]
59struct GraphImage {
60    /// Number of 2D points with at least one correspondence.
61    num_observations: usize,
62    /// Sum of pair match counts.
63    num_correspondences: usize,
64    corrs: Vec<Vec<Correspondence>>,
65}
66
67#[derive(Clone, Debug)]
68struct GraphPair {
69    num_correspondences: usize,
70    /// Smaller id -> larger id direction, inlier list excluded.
71    geometry: TwoViewGeometry,
72}
73
74/// Options for building from a store.
75#[derive(Clone, Debug)]
76pub struct MatchGraphOptions {
77    /// Minimum number of inlier matches for a pair to be used.
78    pub min_num_matches: usize,
79    /// Whether to ignore watermark pairs.
80    pub ignore_watermarks: bool,
81    /// If empty, all images.
82    pub image_names: HashSet<String>,
83    /// If false, only load images connected through "usable pairs".
84    pub keep_all_images: bool,
85}
86
87impl Default for MatchGraphOptions {
88    fn default() -> Self {
89        Self { min_num_matches: 15, ignore_watermarks: false, image_names: HashSet::new(), keep_all_images: false }
90    }
91}
92
93/// Statistics of a pair insertion (number of warnings).
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub struct AddPairStats {
96    /// Number of correspondences added.
97    pub num_added: usize,
98    /// Number of matches dropped because the index was out of range.
99    pub num_out_of_range: usize,
100    /// Number of matches dropped as duplicates.
101    pub num_duplicates: usize,
102    /// Whether the pair was a pair with itself.
103    pub self_pair: bool,
104}
105
106/// Correspondence graph.
107#[derive(Clone, Debug, Default)]
108pub struct MatchGraph {
109    images: HashMap<ImageId, GraphImage>,
110    pairs: HashMap<PairId, GraphPair>,
111    /// Store log position for incremental updates.
112    store_cursor: usize,
113}
114
115impl MatchGraph {
116    /// Empty graph.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Adds a node (an empty correspondence list per 2D point). Ignored if it already exists.
122    pub fn add_image(&mut self, image_id: ImageId, num_points2d: usize) {
123        self.images.entry(image_id).or_insert_with(|| GraphImage { corrs: vec![Vec::new(); num_points2d], ..Default::default() });
124    }
125    /// Whether the image node exists.
126    pub fn exists_image(&self, image_id: ImageId) -> bool {
127        self.images.contains_key(&image_id)
128    }
129    /// Number of image nodes.
130    pub fn num_images(&self) -> usize {
131        self.images.len()
132    }
133    /// Number of pairs.
134    pub fn pair_count(&self) -> usize {
135        self.pairs.len()
136    }
137    /// List of image ids (ascending).
138    pub fn image_ids(&self) -> Vec<ImageId> {
139        let mut v: Vec<_> = self.images.keys().copied().collect();
140        v.sort();
141        v
142    }
143    /// Number of 2D points of the image.
144    pub fn num_points2d(&self, image_id: ImageId) -> usize {
145        self.images.get(&image_id).map_or(0, |i| i.corrs.len())
146    }
147    /// Number of 2D points with at least one correspondence.
148    pub fn observation_count_of(&self, image_id: ImageId) -> usize {
149        self.images.get(&image_id).map_or(0, |i| i.num_observations)
150    }
151    /// Sum of match counts of the pairs the image belongs to.
152    pub fn match_count_of(&self, image_id: ImageId) -> usize {
153        self.images.get(&image_id).map_or(0, |i| i.num_correspondences)
154    }
155    /// Number of matches between two images.
156    pub fn match_count_between(&self, id1: ImageId, id2: ImageId) -> usize {
157        pair_id_of(id1, id2).ok().and_then(|p| self.pairs.get(&p)).map_or(0, |p| p.num_correspondences)
158    }
159    /// Whether a pair exists between two images.
160    pub fn exists_image_pair(&self, id1: ImageId, id2: ImageId) -> bool {
161        pair_id_of(id1, id2).is_ok_and(|p| self.pairs.contains_key(&p))
162    }
163    /// (pair id, match count), ascending by pair id.
164    pub fn image_pairs(&self) -> Vec<(PairId, usize)> {
165        let mut v: Vec<_> = self.pairs.iter().map(|(k, p)| (*k, p.num_correspondences)).collect();
166        v.sort();
167        v
168    }
169
170    /// Adds a pair. Matches are (index in id1, index in id2).
171    /// Both image nodes must exist (otherwise nothing happens). Geometry is stored in the smaller id -> larger id direction.
172    pub fn insert_two_view(&mut self, id1: ImageId, id2: ImageId, tvg: &TwoViewGeometry) -> AddPairStats {
173        let mut st = AddPairStats::default();
174        if id1 == id2 {
175            st.self_pair = true;
176            return st;
177        }
178        let Ok(pid) = pair_id_of(id1, id2) else { return st };
179        if !self.images.contains_key(&id1) || !self.images.contains_key(&id2) || self.pairs.contains_key(&pid) {
180            return st;
181        }
182        let n1 = self.images[&id1].corrs.len();
183        let n2 = self.images[&id2].corrs.len();
184        let mut count = 0usize;
185        for m in &tvg.inlier_matches {
186            if m.idx1 as usize >= n1 || m.idx2 as usize >= n2 {
187                st.num_out_of_range += 1;
188                continue;
189            }
190            let c2 = Correspondence::new(id2, m.idx2);
191            {
192                let im1 = self.images.get_mut(&id1).expect("존재");
193                let list = &mut im1.corrs[m.idx1 as usize];
194                if list.contains(&c2) {
195                    st.num_duplicates += 1;
196                    continue;
197                }
198                if list.is_empty() {
199                    im1.num_observations += 1;
200                }
201                list.push(c2);
202            }
203            {
204                let im2 = self.images.get_mut(&id2).expect("존재");
205                let list = &mut im2.corrs[m.idx2 as usize];
206                if list.is_empty() {
207                    im2.num_observations += 1;
208                }
209                list.push(Correspondence::new(id1, m.idx1));
210            }
211            count += 1;
212        }
213        self.images.get_mut(&id1).expect("존재").num_correspondences += count;
214        self.images.get_mut(&id2).expect("존재").num_correspondences += count;
215        let mut geometry = TwoViewGeometry { inlier_matches: Vec::new(), ..tvg.clone() };
216        if swap_image_pair(id1, id2) {
217            geometry.invert();
218        }
219        self.pairs.insert(pid, GraphPair { num_correspondences: count, geometry });
220        st.num_added = count;
221        st
222    }
223
224    fn usable(tvg: &TwoViewGeometry, opts: &MatchGraphOptions) -> bool {
225        tvg.inlier_matches.len() >= opts.min_num_matches && !(opts.ignore_watermarks && tvg.config == TwoViewGeometryConfig::Watermark)
226    }
227
228    /// Builds from a store.
229    pub fn from_store(store: &FeatureStore, opts: &MatchGraphOptions) -> Self {
230        let mut g = Self::new();
231        g.update_from_store(store, opts);
232        g
233    }
234
235    /// Applies only the pairs newly recorded in the store (incremental). Also adds new image nodes if needed.
236    /// Returns: the number of newly added pairs.
237    pub fn update_from_store(&mut self, store: &FeatureStore, opts: &MatchGraphOptions) -> usize {
238        let (new_pairs, cursor) = store.two_view_geometries_since(self.store_cursor);
239        self.store_cursor = cursor;
240        let name_ok =
241            |id: ImageId| -> bool { opts.image_names.is_empty() || store.image(id).is_some_and(|im| opts.image_names.contains(&im.name)) };
242        if opts.keep_all_images {
243            for im in store.images() {
244                if name_ok(im.image_id) {
245                    self.add_image(im.image_id, store.num_keypoints(im.image_id));
246                }
247            }
248        }
249        // Process deterministically in pair id order and remove duplicate records.
250        let mut by_pid: BTreeMap<PairId, _> = BTreeMap::new();
251        for (pid, t) in new_pairs {
252            by_pid.insert(pid, t);
253        }
254        let mut added = 0;
255        for (pid, t) in by_pid {
256            if self.pairs.contains_key(&pid) || !Self::usable(&t, opts) {
257                continue;
258            }
259            let (a, b) = images_of_pair(pid);
260            if !name_ok(a) || !name_ok(b) || a == b {
261                continue;
262            }
263            for id in [a, b] {
264                if !self.images.contains_key(&id) {
265                    self.add_image(id, store.num_keypoints(id));
266                }
267            }
268            self.insert_two_view(a, b, &t);
269            added += 1;
270        }
271        added
272    }
273
274    /// Direct correspondences of (image, 2D point).
275    pub fn find_correspondences(&self, image_id: ImageId, point2d_idx: Point2DIdx) -> &[Correspondence] {
276        self.images.get(&image_id).and_then(|i| i.corrs.get(point2d_idx as usize)).map_or(&[], |v| v.as_slice())
277    }
278    /// Whether (image, 2D point) has a correspondence.
279    pub fn has_correspondences(&self, image_id: ImageId, point2d_idx: Point2DIdx) -> bool {
280        !self.find_correspondences(image_id, point2d_idx).is_empty()
281    }
282    /// Two-view observation: exactly 1 correspondence and the other side also has exactly 1.
283    pub fn in_two_view_track(&self, image_id: ImageId, point2d_idx: Point2DIdx) -> bool {
284        let c = self.find_correspondences(image_id, point2d_idx);
285        c.len() == 1 && self.find_correspondences(c[0].image_id, c[0].point2d_idx).len() == 1
286    }
287    /// Matches between two images: ascending by image 1 2D point.
288    pub fn matches_between(&self, id1: ImageId, id2: ImageId) -> Vec<FeatureMatch> {
289        let mut out = Vec::new();
290        if let Some(im) = self.images.get(&id1) {
291            for (k, list) in im.corrs.iter().enumerate() {
292                for c in list {
293                    if c.image_id == id2 {
294                        out.push(FeatureMatch::new(k as u32, c.point2d_idx));
295                    }
296                }
297            }
298        }
299        out
300    }
301    /// Transitive correspondences (breadth-first, depth t). The starting observation and already-seen observations are excluded.
302    pub fn transitive_matches(&self, image_id: ImageId, point2d_idx: Point2DIdx, depth: usize) -> Vec<Correspondence> {
303        let start = Correspondence::new(image_id, point2d_idx);
304        let mut seen: HashSet<Correspondence> = HashSet::from([start]);
305        let mut out = Vec::new();
306        let mut q = VecDeque::from([(start, 0usize)]);
307        while let Some((c, d)) = q.pop_front() {
308            if d >= depth {
309                continue;
310            }
311            for n in self.find_correspondences(c.image_id, c.point2d_idx) {
312                if seen.insert(*n) {
313                    out.push(*n);
314                    q.push_back((*n, d + 1));
315                }
316            }
317        }
318        out
319    }
320    /// Two-view geometry in the (id1 -> id2) direction (inliers excluded).
321    pub fn two_view_geometry(&self, id1: ImageId, id2: ImageId) -> Option<TwoViewGeometry> {
322        let pid = pair_id_of(id1, id2).ok()?;
323        let g = &self.pairs.get(&pid)?.geometry;
324        Some(if swap_image_pair(id1, id2) { g.inverted() } else { g.clone() })
325    }
326    /// Updates with the geometry in the (id1 -> id2) direction.
327    pub fn replace_two_view(&mut self, id1: ImageId, id2: ImageId, tvg: &TwoViewGeometry) -> bool {
328        let Ok(pid) = pair_id_of(id1, id2) else { return false };
329        let Some(p) = self.pairs.get_mut(&pid) else { return false };
330        let mut g = TwoViewGeometry { inlier_matches: Vec::new(), ..tvg.clone() };
331        if swap_image_pair(id1, id2) {
332            g.invert();
333        }
334        p.geometry = g;
335        true
336    }
337
338    /// Union-find tracks (connected components). Only pairs for which `pair_filter(smaller id, larger id)` is true are used.
339    /// Each component is ascending by (image, point); the component list is ascending by first element. There are no length-1 components.
340    pub fn union_find_tracks<F>(&self, pair_filter: F) -> Vec<Vec<Correspondence>>
341    where
342        F: Fn(ImageId, ImageId) -> bool,
343    {
344        let mut index: HashMap<Correspondence, usize> = HashMap::new();
345        let mut nodes: Vec<Correspondence> = Vec::new();
346        let mut parent: Vec<usize> = Vec::new();
347        fn find(p: &mut [usize], mut x: usize) -> usize {
348            while p[x] != x {
349                p[x] = p[p[x]];
350                x = p[x];
351            }
352            x
353        }
354        let mut get = |c: Correspondence, nodes: &mut Vec<Correspondence>, parent: &mut Vec<usize>| -> usize {
355            *index.entry(c).or_insert_with(|| {
356                nodes.push(c);
357                parent.push(parent.len());
358                parent.len() - 1
359            })
360        };
361        let mut pairs: Vec<PairId> = self.pairs.keys().copied().collect();
362        pairs.sort();
363        for pid in pairs {
364            let (a, b) = images_of_pair(pid);
365            if !pair_filter(a, b) {
366                continue;
367            }
368            for m in self.matches_between(a, b) {
369                let x = get(Correspondence::new(a, m.idx1), &mut nodes, &mut parent);
370                let y = get(Correspondence::new(b, m.idx2), &mut nodes, &mut parent);
371                let (rx, ry) = (find(&mut parent, x), find(&mut parent, y));
372                if rx != ry {
373                    // Lexicographically, put the larger root under the smaller one.
374                    if nodes[rx] < nodes[ry] {
375                        parent[ry] = rx;
376                    } else {
377                        parent[rx] = ry;
378                    }
379                }
380            }
381        }
382        let mut comps: BTreeMap<usize, Vec<Correspondence>> = BTreeMap::new();
383        for (i, node) in nodes.iter().enumerate() {
384            let r = find(&mut parent, i);
385            comps.entry(r).or_default().push(*node);
386        }
387        let mut out: Vec<Vec<Correspondence>> = comps
388            .into_values()
389            .filter(|v| v.len() > 1)
390            .map(|mut v| {
391                v.sort();
392                v
393            })
394            .collect();
395        out.sort();
396        out
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use crate::camera::{Camera, CameraModelKind};
404    use crate::features::Keypoint;
405    use crate::geometry::{Quat, Rigid3, Vec3};
406
407    fn tvg(m: &[(u32, u32)]) -> TwoViewGeometry {
408        TwoViewGeometry {
409            config: TwoViewGeometryConfig::Calibrated,
410            inlier_matches: m.iter().map(|&(a, b)| FeatureMatch::new(a, b)).collect(),
411            ..Default::default()
412        }
413    }
414
415    #[test]
416    fn add_pair_rules() {
417        let mut g = MatchGraph::new();
418        g.add_image(1, 10);
419        g.add_image(2, 10);
420        g.add_image(3, 10);
421        let st = g.insert_two_view(1, 2, &tvg(&[(0, 0), (1, 1), (1, 1), (20, 2), (2, 30), (3, 3)]));
422        assert_eq!(st.num_added, 3);
423        assert_eq!(st.num_duplicates, 1);
424        assert_eq!(st.num_out_of_range, 2);
425        assert!(g.insert_two_view(3, 3, &tvg(&[(0, 0)])).self_pair);
426        assert_eq!(g.match_count_between(2, 1), 3);
427        assert_eq!(g.observation_count_of(1), 3);
428        assert_eq!(g.match_count_of(2), 3);
429        // reversed direction pair
430        g.insert_two_view(3, 2, &tvg(&[(5, 0), (6, 1)]));
431        assert_eq!(g.observation_count_of(2), 3);
432        assert_eq!(g.match_count_of(2), 5);
433        assert_eq!(g.matches_between(2, 3), vec![FeatureMatch::new(0, 5), FeatureMatch::new(1, 6)]);
434        assert!(g.in_two_view_track(1, 3));
435        assert!(!g.in_two_view_track(1, 0)); // 2:0 has two corrs
436        let t = g.transitive_matches(1, 0, 2);
437        assert_eq!(t, vec![Correspondence::new(2, 0), Correspondence::new(3, 5)]);
438        assert_eq!(g.transitive_matches(1, 0, 1).len(), 1);
439    }
440
441    #[test]
442    fn geometry_direction() {
443        let mut g = MatchGraph::new();
444        g.add_image(1, 1);
445        g.add_image(2, 1);
446        let pose = Rigid3::new(Quat::from_axis_angle(&Vec3::new(1.0, 2.0, 3.0), 0.7), Vec3::new(0.3, -0.2, 1.0));
447        let t = TwoViewGeometry { cam1_to_cam2: Some(pose), ..tvg(&[(0, 0)]) };
448        g.insert_two_view(2, 1, &t);
449        let back = g.two_view_geometry(2, 1).unwrap().cam1_to_cam2.unwrap();
450        assert!(back.rotation.angular_distance(&pose.rotation) < 1e-12);
451        let inv = g.two_view_geometry(1, 2).unwrap().cam1_to_cam2.unwrap();
452        let id = inv * pose;
453        assert!(id.rotation.angular_distance(&Quat::IDENTITY) < 1e-12);
454        assert!(id.translation.norm() < 1e-12);
455    }
456
457    #[test]
458    fn incremental_from_store_and_tracks() {
459        let s = FeatureStore::new();
460        let c = s.add_camera(Camera::from_focal(CameraModelKind::OpenCv, 100.0, 100, 100)).unwrap();
461        let ids: Vec<_> = (0..4).map(|k| s.add_image(&format!("{k}.jpg"), c).unwrap()).collect();
462        for &i in &ids {
463            s.set_keypoints(i, vec![Keypoint::new(0.5, 0.5); 20]);
464        }
465        let full: Vec<(u32, u32)> = (0..15).map(|k| (k, k)).collect();
466        s.put_two_view(ids[0], ids[1], &tvg(&full)).unwrap();
467        s.put_two_view(ids[1], ids[2], &tvg(&full)).unwrap();
468        s.put_two_view(ids[2], ids[3], &tvg(&full[..14])).unwrap(); // too few
469        let opts = MatchGraphOptions::default();
470        let mut g = MatchGraph::from_store(&s, &opts);
471        assert_eq!(g.num_images(), 3);
472        assert_eq!(g.pair_count(), 2);
473        // incremental: new pair arrives
474        s.put_two_view(ids[3], ids[0], &tvg(&full)).unwrap();
475        assert_eq!(g.update_from_store(&s, &opts), 1);
476        assert_eq!(g.update_from_store(&s, &opts), 0);
477        assert_eq!(g.num_images(), 4);
478        let tracks = g.union_find_tracks(|_, _| true);
479        assert_eq!(tracks.len(), 15);
480        assert!(tracks.iter().all(|t| t.len() == 4));
481        let tracks = g.union_find_tracks(|a, b| !(a == ids[1] && b == ids[2]));
482        assert!(tracks.iter().all(|t| t.len() == 3));
483    }
484}