Skip to main content

kornia_slam/loop_closure/
closer.rs

1//! Loop closing for one keyframe: place recognition, verification, episode
2//! consistency, pose-graph optimization and map writeback.
3//!
4//! [`LoopCloser`] owns the bag-of-words database and the acceptance history,
5//! and decides what happens to the map. It reports what the runtime must apply
6//! to its own tracking state through [`LoopClosingOutcome`] rather than
7//! reaching into it.
8
9use std::collections::HashSet;
10
11use kornia_3d::camera::PinholeCamera;
12use kornia_3d::pose::Pose3d;
13use kornia_algebra::Vec3F64;
14
15use crate::loop_closure::place_recognition::{
16    Candidate, KeyFrameDatabase, Vocabulary, compute_bow,
17};
18use crate::loop_closure::{
19    InertialPgoContext, LoopEpisodeConfig, LoopEpisodeDecision, LoopEpisodeTracker,
20    LoopFusionConfig, LoopVerificationConfig, PgoConfig, VerifiedLoopEdge, fuse_verified_loop,
21    optimize_pose_graph, verify_loop_candidate,
22};
23use crate::mapping::Map;
24use crate::pose_conversion::apply_reference_pose_correction;
25
26#[derive(Debug, Clone, Default)]
27pub struct LoopClosingConfig {
28    /// Mono+IMU maps become metric only after inertial initialization. Stereo
29    /// maps are metric from bootstrap and leave this disabled.
30    pub require_imu_initialized: bool,
31    pub episode: LoopEpisodeConfig,
32    pub fusion: LoopFusionConfig,
33    pub verification: LoopVerificationConfig,
34    pub optimizer: PgoConfig,
35}
36
37/// Concise externally visible result of a loop-closure attempt.
38#[derive(Debug, Clone)]
39pub enum LoopClosureEvent {
40    Accepted {
41        edge: VerifiedLoopEdge,
42        applied: bool,
43    },
44    PgoFailed {
45        query_kf_idx: usize,
46        candidate_kf_idx: usize,
47        reason: String,
48    },
49}
50
51/// Tracking state the loop closer reads but never owns.
52#[derive(Debug, Clone, Copy)]
53pub(crate) struct LoopClosingContext {
54    pub pose_world_to_cam: Pose3d,
55    pub velocity_world: Vec3F64,
56    pub current_keyframe_idx: Option<usize>,
57    pub imu_initialized: bool,
58    pub gravity_world: Vec3F64,
59}
60
61/// What the runtime must apply after a loop-closure attempt.
62#[derive(Debug, Default)]
63pub(crate) struct LoopClosingOutcome {
64    /// The best place-recognition match, for the runtime's debug log.
65    pub debug_message: Option<String>,
66    pub events: Vec<LoopClosureEvent>,
67    /// Corrected `(pose_world_to_cam, velocity_world)`, set only when the map
68    /// was actually corrected.
69    pub tracking_correction: Option<(Pose3d, Vec3F64)>,
70    pub pgo_applied: bool,
71}
72
73/// Place recognition for every keyframe, and loop closing when configured.
74///
75/// Keyframes are indexed whenever a vocabulary is set, even with closing
76/// disabled or held back until inertial initialization, so the database is
77/// complete by the time a closure is allowed.
78pub(crate) struct LoopCloser {
79    vocabulary: Option<Vocabulary>,
80    kf_database: KeyFrameDatabase,
81    acceptance: Option<LoopAcceptance>,
82}
83
84impl LoopCloser {
85    /// `pgo` enables verification and correction; without it keyframes are
86    /// only indexed.
87    pub(crate) fn new(pgo: Option<LoopClosingConfig>) -> Self {
88        Self {
89            vocabulary: None,
90            kf_database: KeyFrameDatabase::new(),
91            acceptance: pgo.map(LoopAcceptance::new),
92        }
93    }
94
95    /// Enables appearance-based loop detection with a bag-of-words vocabulary.
96    pub(crate) fn set_vocabulary(&mut self, vocabulary: Vocabulary) {
97        self.vocabulary = Some(vocabulary);
98    }
99
100    /// Indexes a freshly inserted keyframe for place recognition, queries the
101    /// database for appearance-based loop candidates, and closes a verified
102    /// loop when closing is configured and allowed.
103    ///
104    /// Mirrors ORB-SLAM3's `LoopClosing::DetectLoop`: the acceptance threshold is
105    /// the lowest BoW similarity to a covisible neighbour, and the covisibility
106    /// set is excluded so only a revisited place can match. The query runs before
107    /// this keyframe is added, so it never matches itself.
108    pub(crate) fn on_keyframe(
109        &mut self,
110        map: &mut Map,
111        camera: &PinholeCamera,
112        kf_idx: usize,
113        context: LoopClosingContext,
114    ) -> LoopClosingOutcome {
115        let Some(vocabulary) = self.vocabulary.as_ref() else {
116            return LoopClosingOutcome::default();
117        };
118        const MIN_COVIS_WEIGHT: usize = 15;
119        let Some(kf) = map.get_keyframe(kf_idx) else {
120            return LoopClosingOutcome::default();
121        };
122        let bow = compute_bow(vocabulary, &kf.frame.features.descriptors);
123        if bow.0.is_empty() {
124            return LoopClosingOutcome::default();
125        }
126        let neighbors = map.covisible_keyframes_min_weight(kf_idx, MIN_COVIS_WEIGHT);
127        let candidates = self.kf_database.detect_loop_candidates(
128            kf_idx,
129            &bow,
130            neighbors.iter().map(|&(nb_idx, _w)| nb_idx),
131        );
132        self.kf_database.add(kf_idx, bow);
133
134        let debug_message = candidates.first().map(|best| {
135            format!(
136                "[loop] kf={kf_idx} matched kf={} score={:.3} shared_words={} ({} candidates)",
137                best.kf_idx,
138                best.score,
139                best.shared_words,
140                candidates.len()
141            )
142        });
143
144        let Some(acceptance) = self.acceptance.as_mut() else {
145            return LoopClosingOutcome {
146                debug_message,
147                ..Default::default()
148            };
149        };
150        if acceptance.requires_imu_initialized() && !context.imu_initialized {
151            return LoopClosingOutcome {
152                debug_message,
153                ..Default::default()
154            };
155        }
156        LoopClosingOutcome {
157            debug_message,
158            ..acceptance.close(map, camera, kf_idx, &candidates, context)
159        }
160    }
161}
162
163#[cfg(test)]
164impl LoopCloser {
165    pub(crate) fn indexed_keyframes(&self) -> usize {
166        self.kf_database.len()
167    }
168
169    pub(crate) fn verified_loop_count(&self) -> usize {
170        self.acceptance
171            .as_ref()
172            .map_or(0, |acceptance| acceptance.verified_loops.len())
173    }
174
175    pub(crate) fn mark_verified_for_test(&mut self, a: usize, b: usize) {
176        if let Some(acceptance) = self.acceptance.as_mut() {
177            acceptance
178                .verified_loop_pairs
179                .insert(normalized_loop_pair(a, b));
180        }
181    }
182}
183
184/// Verifies loop candidates and applies accepted closures to the map, keeping
185/// the history that makes acceptance consistent across keyframes.
186struct LoopAcceptance {
187    config: LoopClosingConfig,
188    episode_tracker: LoopEpisodeTracker,
189    verified_loops: Vec<VerifiedLoopEdge>,
190    verified_loop_pairs: HashSet<(usize, usize)>,
191}
192
193impl LoopAcceptance {
194    fn new(config: LoopClosingConfig) -> Self {
195        let episode_tracker = LoopEpisodeTracker::new(config.episode);
196        Self {
197            config,
198            episode_tracker,
199            verified_loops: Vec::new(),
200            verified_loop_pairs: HashSet::new(),
201        }
202    }
203
204    /// Mono+IMU maps are only metric once inertial initialization has run, so
205    /// correcting them before that is not meaningful.
206    fn requires_imu_initialized(&self) -> bool {
207        self.config.require_imu_initialized
208    }
209
210    /// Verifies `candidates` against `kf_idx`, and on an accepted closure
211    /// optimizes the pose graph, corrects the map and fuses the loop.
212    fn close(
213        &mut self,
214        map: &mut Map,
215        camera: &PinholeCamera,
216        kf_idx: usize,
217        candidates: &[Candidate],
218        context: LoopClosingContext,
219    ) -> LoopClosingOutcome {
220        let inertial_pgo = context.imu_initialized.then_some(InertialPgoContext {
221            gravity_world: context.gravity_world,
222        });
223        let mut outcome = LoopClosingOutcome::default();
224        let mut accepted = None;
225        for candidate in candidates {
226            let pair = normalized_loop_pair(kf_idx, candidate.kf_idx);
227            if self.verified_loop_pairs.contains(&pair) {
228                continue;
229            }
230            if let Ok(edge) = verify_loop_candidate(
231                map,
232                camera,
233                kf_idx,
234                candidate.kf_idx,
235                &self.config.verification,
236            ) {
237                let query_order = map
238                    .keyframes()
239                    .iter()
240                    .position(|keyframe| keyframe.frame.idx == kf_idx)
241                    .expect("verified query keyframe must be in the map");
242                let candidate_order = map
243                    .keyframes()
244                    .iter()
245                    .position(|keyframe| keyframe.frame.idx == candidate.kf_idx)
246                    .expect("verified candidate keyframe must be in the map");
247                let decision =
248                    self.episode_tracker
249                        .observe(query_order, candidate_order, edge.clone());
250                match decision {
251                    LoopEpisodeDecision::Pending { .. }
252                    | LoopEpisodeDecision::Suppressed { .. } => {}
253                    LoopEpisodeDecision::Ready { representative, .. } => {
254                        let pair = normalized_loop_pair(
255                            representative.query_kf_idx,
256                            representative.candidate_kf_idx,
257                        );
258                        let mut loops = self.verified_loops.clone();
259                        loops.push(representative.clone());
260                        match optimize_pose_graph(map, &loops, &self.config.optimizer, inertial_pgo)
261                        {
262                            Ok(result) => {
263                                if result.usable {
264                                    let tracking_correction = context
265                                        .current_keyframe_idx
266                                        .and_then(|reference_kf_idx| {
267                                            pose_graph_reference_correction(
268                                                reference_kf_idx,
269                                                &result.keyframe_indices,
270                                                &result.original_poses,
271                                                &result.optimized_poses,
272                                            )
273                                        })
274                                        .map(|(reference_before, reference_after, world)| {
275                                            (
276                                                apply_reference_pose_correction(
277                                                    context.pose_world_to_cam,
278                                                    reference_before,
279                                                    reference_after,
280                                                ),
281                                                world.rotation * context.velocity_world,
282                                            )
283                                        });
284                                    if let Some(tracking_correction) = tracking_correction {
285                                        match map.apply_pose_graph_correction(
286                                            &result.keyframe_indices,
287                                            &result.original_poses,
288                                            &result.optimized_poses,
289                                        ) {
290                                            Ok(_) => {
291                                                fuse_verified_loop(
292                                                    map,
293                                                    camera,
294                                                    &representative,
295                                                    &self.config.fusion,
296                                                );
297                                                outcome.tracking_correction =
298                                                    Some(tracking_correction);
299                                                outcome.pgo_applied = true;
300                                            }
301                                            Err(error) => {
302                                                outcome.events.push(LoopClosureEvent::PgoFailed {
303                                                    query_kf_idx: representative.query_kf_idx,
304                                                    candidate_kf_idx: representative
305                                                        .candidate_kf_idx,
306                                                    reason: format!(
307                                                        "live map correction rejected: {error}"
308                                                    ),
309                                                })
310                                            }
311                                        }
312                                    } else {
313                                        outcome.events.push(LoopClosureEvent::PgoFailed {
314                                            query_kf_idx: representative.query_kf_idx,
315                                            candidate_kf_idx: representative.candidate_kf_idx,
316                                            reason:
317                                                "current reference keyframe is outside the PGO snapshot"
318                                                    .into(),
319                                        });
320                                    }
321                                }
322                            }
323                            Err(error) => outcome.events.push(LoopClosureEvent::PgoFailed {
324                                query_kf_idx: representative.query_kf_idx,
325                                candidate_kf_idx: representative.candidate_kf_idx,
326                                reason: error.to_string(),
327                            }),
328                        }
329                        outcome.events.push(LoopClosureEvent::Accepted {
330                            edge: representative.clone(),
331                            applied: outcome.pgo_applied,
332                        });
333                        accepted = Some((pair, representative));
334                    }
335                }
336                break;
337            }
338        }
339        if let Some((pair, edge)) = accepted {
340            self.verified_loop_pairs.insert(pair);
341            self.verified_loops.push(edge);
342        }
343        outcome
344    }
345}
346
347fn normalized_loop_pair(a: usize, b: usize) -> (usize, usize) {
348    if a <= b { (a, b) } else { (b, a) }
349}
350
351#[cfg(test)]
352fn pose_graph_tracking_correction(
353    current_pose: Pose3d,
354    reference_kf_idx: usize,
355    keyframe_indices: &[usize],
356    poses_before: &[Pose3d],
357    poses_after: &[Pose3d],
358) -> Option<Pose3d> {
359    let (reference_before, reference_after, _) = pose_graph_reference_correction(
360        reference_kf_idx,
361        keyframe_indices,
362        poses_before,
363        poses_after,
364    )?;
365    Some(apply_reference_pose_correction(
366        current_pose,
367        reference_before,
368        reference_after,
369    ))
370}
371
372fn pose_graph_reference_correction(
373    reference_kf_idx: usize,
374    keyframe_indices: &[usize],
375    poses_before: &[Pose3d],
376    poses_after: &[Pose3d],
377) -> Option<(Pose3d, Pose3d, Pose3d)> {
378    let node = keyframe_indices
379        .iter()
380        .position(|&keyframe_idx| keyframe_idx == reference_kf_idx)?;
381    let reference_before = *poses_before.get(node)?;
382    let reference_after = *poses_after.get(node)?;
383    let world_correction = reference_after.inverse().compose(&reference_before);
384    Some((reference_before, reference_after, world_correction))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::LoopClosingConfig;
390    use super::{
391        LoopAcceptance, LoopClosingContext, pose_graph_reference_correction,
392        pose_graph_tracking_correction,
393    };
394    use crate::loop_closure::place_recognition::Candidate;
395    use crate::mapping::Map;
396    use crate::pose_conversion::apply_reference_pose_correction;
397    use kornia_3d::camera::PinholeCamera;
398    use kornia_3d::pose::Pose3d;
399    use kornia_algebra::{SO3F64, Vec3F64};
400
401    fn assert_pose_close(actual: Pose3d, expected: Pose3d) {
402        assert!((actual.translation - expected.translation).length() < 1e-10);
403        for (actual, expected) in actual
404            .rotation
405            .to_cols_array()
406            .iter()
407            .zip(expected.rotation.to_cols_array())
408        {
409            assert!((actual - expected).abs() < 1e-10);
410        }
411    }
412
413    #[test]
414    fn reference_pose_correction_preserves_relative_camera_pose() {
415        let reference_before = Pose3d::new(
416            SO3F64::exp(Vec3F64::new(0.1, -0.2, 0.3)).matrix(),
417            Vec3F64::new(-1.0, 0.5, 0.2),
418        );
419        let relative_pose = Pose3d::new(
420            SO3F64::exp(Vec3F64::new(-0.15, 0.05, 0.2)).matrix(),
421            Vec3F64::new(-0.5, 0.1, 0.3),
422        );
423        let current_before = relative_pose.compose(&reference_before);
424        let reference_after = Pose3d::new(
425            SO3F64::exp(Vec3F64::new(0.25, 0.1, -0.1)).matrix(),
426            Vec3F64::new(-2.0, -0.3, 0.8),
427        );
428
429        let corrected =
430            apply_reference_pose_correction(current_before, reference_before, reference_after);
431
432        assert_pose_close(Pose3d::between(&reference_after, &corrected), relative_pose);
433    }
434
435    #[test]
436    fn pose_graph_tracking_correction_uses_current_reference_keyframe() {
437        let reference_before = Pose3d::new(
438            SO3F64::exp(Vec3F64::new(0.1, -0.2, 0.3)).matrix(),
439            Vec3F64::new(-1.0, 0.5, 0.2),
440        );
441        let reference_after = Pose3d::new(
442            SO3F64::exp(Vec3F64::new(0.25, 0.1, -0.1)).matrix(),
443            Vec3F64::new(-2.0, -0.3, 0.8),
444        );
445        let relative_pose = Pose3d::new(
446            SO3F64::exp(Vec3F64::new(-0.15, 0.05, 0.2)).matrix(),
447            Vec3F64::new(-0.5, 0.1, 0.3),
448        );
449        let current_before = relative_pose.compose(&reference_before);
450
451        let corrected = pose_graph_tracking_correction(
452            current_before,
453            20,
454            &[10, 20],
455            &[Pose3d::IDENTITY, reference_before],
456            &[Pose3d::IDENTITY, reference_after],
457        )
458        .unwrap();
459
460        assert_pose_close(Pose3d::between(&reference_after, &corrected), relative_pose);
461        assert!(
462            pose_graph_tracking_correction(
463                current_before,
464                99,
465                &[10, 20],
466                &[Pose3d::IDENTITY, reference_before],
467                &[Pose3d::IDENTITY, reference_after],
468            )
469            .is_none()
470        );
471    }
472
473    #[test]
474    fn pose_graph_reference_correction_rotates_live_world_velocity() {
475        let yaw = SO3F64::exp(Vec3F64::new(0.0, 0.4, 0.0)).matrix();
476        let reference_before = Pose3d::IDENTITY;
477        let reference_after = Pose3d::new(yaw.transpose(), Vec3F64::ZERO);
478        let (_, _, correction) = pose_graph_reference_correction(
479            20,
480            &[10, 20],
481            &[Pose3d::IDENTITY, reference_before],
482            &[Pose3d::IDENTITY, reference_after],
483        )
484        .unwrap();
485        let velocity = Vec3F64::new(1.0, 0.2, -0.5);
486
487        let corrected = correction.rotation * velocity;
488
489        assert!((corrected - yaw * velocity).length() < 1e-10);
490    }
491
492    fn test_camera() -> PinholeCamera {
493        PinholeCamera {
494            fx: 400.0,
495            fy: 400.0,
496            cx: 320.0,
497            cy: 240.0,
498            k1: 0.0,
499            k2: 0.0,
500            p1: 0.0,
501            p2: 0.0,
502        }
503    }
504
505    fn context() -> LoopClosingContext {
506        LoopClosingContext {
507            pose_world_to_cam: Pose3d::IDENTITY,
508            velocity_world: Vec3F64::new(1.0, 0.0, 0.0),
509            current_keyframe_idx: Some(0),
510            imu_initialized: false,
511            gravity_world: Vec3F64::new(0.0, 0.0, -9.81),
512        }
513    }
514
515    /// An attempt that verifies nothing must leave the runtime's tracking state
516    /// alone: no correction to apply, no PGO, nothing to report.
517    #[test]
518    fn outcome_is_inert_when_no_candidate_verifies() {
519        let mut closer = LoopAcceptance::new(LoopClosingConfig::default());
520        let mut map = Map::new();
521        let candidates = [Candidate {
522            kf_idx: 7,
523            shared_words: 40,
524            score: 0.9,
525        }];
526
527        let outcome = closer.close(&mut map, &test_camera(), 3, &candidates, context());
528
529        assert!(outcome.tracking_correction.is_none());
530        assert!(!outcome.pgo_applied);
531        assert!(outcome.events.is_empty());
532    }
533
534    /// With no candidates at all the closer must not touch the map or report.
535    #[test]
536    fn outcome_is_inert_without_candidates() {
537        let mut closer = LoopAcceptance::new(LoopClosingConfig::default());
538        let mut map = Map::new();
539
540        let outcome = closer.close(&mut map, &test_camera(), 0, &[], context());
541
542        assert!(outcome.tracking_correction.is_none());
543        assert!(!outcome.pgo_applied);
544        assert!(outcome.events.is_empty());
545        assert_eq!(map.keyframes().len(), 0);
546    }
547
548    /// The runtime asks before building a context; mono+IMU maps are not metric
549    /// until inertial initialization has run.
550    #[test]
551    fn imu_requirement_is_reported_from_config() {
552        let closer = LoopAcceptance::new(LoopClosingConfig {
553            require_imu_initialized: true,
554            ..LoopClosingConfig::default()
555        });
556        assert!(closer.requires_imu_initialized());
557
558        let closer = LoopAcceptance::new(LoopClosingConfig::default());
559        assert!(!closer.requires_imu_initialized());
560    }
561
562    /// A correction the runtime applies must move the pose and rotate the world
563    /// velocity by the same world correction.
564    #[test]
565    fn reference_correction_yields_pose_and_velocity_together() {
566        let yaw = SO3F64::exp(Vec3F64::new(0.0, 0.3, 0.0)).matrix();
567        let reference_before = Pose3d::IDENTITY;
568        let reference_after = Pose3d::new(yaw.transpose(), Vec3F64::ZERO);
569        let (before, after, world) =
570            pose_graph_reference_correction(4, &[4], &[reference_before], &[reference_after])
571                .expect("reference keyframe is in the snapshot");
572
573        let pose = apply_reference_pose_correction(Pose3d::IDENTITY, before, after);
574        let velocity = world.rotation * Vec3F64::new(1.0, 0.0, 0.0);
575
576        assert_pose_close(pose, reference_after);
577        assert!((velocity.length() - 1.0).abs() < 1e-10);
578        assert!(velocity.x < 1.0);
579    }
580}