1use 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 pub require_imu_initialized: bool,
31 pub episode: LoopEpisodeConfig,
32 pub fusion: LoopFusionConfig,
33 pub verification: LoopVerificationConfig,
34 pub optimizer: PgoConfig,
35}
36
37#[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#[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#[derive(Debug, Default)]
63pub(crate) struct LoopClosingOutcome {
64 pub debug_message: Option<String>,
66 pub events: Vec<LoopClosureEvent>,
67 pub tracking_correction: Option<(Pose3d, Vec3F64)>,
70 pub pgo_applied: bool,
71}
72
73pub(crate) struct LoopCloser {
79 vocabulary: Option<Vocabulary>,
80 kf_database: KeyFrameDatabase,
81 acceptance: Option<LoopAcceptance>,
82}
83
84impl LoopCloser {
85 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 pub(crate) fn set_vocabulary(&mut self, vocabulary: Vocabulary) {
97 self.vocabulary = Some(vocabulary);
98 }
99
100 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
184struct 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 fn requires_imu_initialized(&self) -> bool {
207 self.config.require_imu_initialized
208 }
209
210 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 #[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 #[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 #[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 #[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}