ReUseX  0.0.5
3D Point Cloud Processing for Building Reuse
Loading...
Searching...
No Matches
LoopClosure.hpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Povl Filip Sonne-Frederiksen
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4//
5// P2 of issue #225: wide-baseline loop-closure edges for the plane-landmark
6// pose graph.
7//
8// The plane-landmark back-end (PlaneGraphOptimizer) fixes *local* global
9// consistency where frames co-observe the same walls, but it cannot constrain
10// two temporally distant views of the same place that share no plane landmark
11// chain — the "basin problem" from docs/research/registration-improvements.md.
12// This module supplies the missing wide-baseline constraints: it proposes
13// loop-candidate frame pairs, matches them with image features, lifts the
14// matches to metric 3D using the stored per-frame depth, and estimates a robust
15// metric relative pose (RANSAC 3D-3D). Each accepted pair becomes a
16// gtsam::BetweenFactor<Pose3> loop edge fed into the SAME GncOptimizer graph,
17// so a wrong edge is down-weighted rather than corrupting the solution.
18//
19// Front-end design (research doc §Q2, commercial-safe path): the matcher is
20// abstracted behind FrameMatcher so the default OpenCV ORB detector+matcher
21// (BSD, no model weights, verifiable offline) can be swapped for a learned
22// matcher (EfficientLoFTR / LightGlue+ALIKED via the existing TensorRT backend)
23// without touching the graph wiring. The bundled superpoint.pt is
24// non-commercial and deliberately NOT used here.
25//
26// This header carries no GTSAM include (STANDARDS.md §2); the factor lives in
27// PlaneGraphOptimizer.cpp.
28
29#pragma once
30
31#include <Eigen/Core>
32#include <Eigen/Geometry>
33
34#include <array>
35#include <random>
36#include <string>
37#include <vector>
38
39namespace reusex {
40class ProjectDB;
41}
42
43namespace reusex::geometry {
44
50struct LoopEdge {
51 int i = -1;
52 int j = -1;
54 Eigen::Matrix4d T_ij = Eigen::Matrix4d::Identity();
55 double sigma_rot = 0.05;
56 double sigma_trans = 0.05;
57 int inliers = 0;
58};
59
84
87 bool enable =
88 false;
89
95 int exhaustive_budget = 80000;
96
97 // --- Candidate proposal (common) -----------------------------------------
104
105 // --- Spatial proposal only -----------------------------------------------
109 float max_view_angle = 45.0f;
110
111 // --- Appearance proposal only (bag-of-words over ORB) --------------------
112 int vocab_size = 400;
115
116 // --- Feature matching + robust relative pose -----------------------------
117 // Verification defaults are tuned to actually surface wide-baseline loops on
118 // iPad-LiDAR scans: 1500 features / 0.05 m were too strict and found nothing;
119 // 3000 features + a 0.10 m 3D-3D threshold (depth is noisy at range) recover
120 // strong loops (measured: a start/end pair with ~90 RANSAC inliers).
121 int max_features = 3000;
122 float ratio_test = 0.85f;
124 float ransac_inlier_dist = 0.10f;
126 float min_depth = 0.3f;
127 float max_depth = 5.0f;
164
165 // --- Edge noise ----------------------------------------------------------
172 float base_sigma_trans = 0.10f;
173 float base_sigma_rot = 0.05f;
174 float min_sigma_trans = 0.04f;
175 float min_sigma_rot = 0.02f;
176
177 // --- Pairwise Consistency Maximization (false-positive rejection) --------
183 bool pcm = true;
184 float pcm_trans_threshold = 0.30f;
185 float pcm_rot_threshold = 0.15f;
188 int pcm_max_edges = 3000;
189
190 unsigned seed = 42;
191};
192
206double trajectory_extent(const std::vector<Eigen::Matrix4d> &poses);
207
221double seed_disagreement_gate(double extent, double fraction, double floor_m);
222
225 int candidates = 0;
226 int edges = 0;
228};
229
242std::vector<LoopEdge>
243detect_loop_edges(ProjectDB &db, const std::vector<int> &node_ids,
244 const std::vector<Eigen::Matrix4d> &seed_poses,
245 const LoopClosureOptions &options,
246 LoopClosureResult *out_result = nullptr);
247
297std::vector<LoopEdge> load_loop_edges(const std::string &path,
298 const std::vector<int> &node_ids,
299 LoopClosureResult *out_result = nullptr);
300
321std::vector<LoopEdge>
322filter_consistent_loop_edges(std::vector<LoopEdge> edges,
323 const std::vector<Eigen::Matrix4d> &seed,
324 const LoopClosureOptions &options);
325
331namespace detail {
332
339std::vector<int> ransac_rigid(const Eigen::Matrix3Xd &src,
340 const Eigen::Matrix3Xd &dst,
341 const LoopClosureOptions &opt, std::mt19937 &rng,
342 Eigen::Matrix4d &best_T);
343
349std::vector<LoopEdge> pcm_filter(std::vector<LoopEdge> edges,
350 const std::vector<Eigen::Matrix4d> &seed,
351 const LoopClosureOptions &opt);
352
353} // namespace detail
354
355} // namespace reusex::geometry
Internals of detect_loop_edges, exposed ONLY so the geometry stages of the pipeline can be regression...
std::vector< LoopEdge > pcm_filter(std::vector< LoopEdge > edges, const std::vector< Eigen::Matrix4d > &seed, const LoopClosureOptions &opt)
Pairwise Consistency Maximization (Mangelson et al.
std::vector< int > ransac_rigid(const Eigen::Matrix3Xd &src, const Eigen::Matrix3Xd &dst, const LoopClosureOptions &opt, std::mt19937 &rng, Eigen::Matrix4d &best_T)
RANSAC 3D-3D rigid fit: estimate best_T with dst ~= best_T * src from putative correspondences (both ...
LoopProposal
How loop-candidate frame pairs are proposed before geometric verification.
@ automatic
Pick automatically (default): exhaustive when the temporally-distant pair count is within exhaustive_...
@ exhaustive
Every temporally distant pair (i, j) with j - i >= min_frame_gap.
@ appearance
Appearance shortlist from a pose-INDEPENDENT bag-of-words over the frames' own ORB descriptors.
@ spatial
Spatial shortlist from the SEED poses (camera-centre proximity + view agreement).
double trajectory_extent(const std::vector< Eigen::Matrix4d > &poses)
Spatial extent of a trajectory: the diagonal of the axis-aligned bounding box of the camera centres,...
double seed_disagreement_gate(double extent, double fraction, double floor_m)
Effective seed-disagreement gate in metres, combining a scale-relative term with an absolute floor: m...
std::vector< LoopEdge > load_loop_edges(const std::string &path, const std::vector< int > &node_ids, LoopClosureResult *out_result=nullptr)
Load externally-computed loop edges from a JSON file and map them onto the optimizer's frame indices.
std::vector< LoopEdge > detect_loop_edges(ProjectDB &db, const std::vector< int > &node_ids, const std::vector< Eigen::Matrix4d > &seed_poses, const LoopClosureOptions &options, LoopClosureResult *out_result=nullptr)
Detect wide-baseline loop edges among the given frames.
std::vector< LoopEdge > filter_consistent_loop_edges(std::vector< LoopEdge > edges, const std::vector< Eigen::Matrix4d > &seed, const LoopClosureOptions &options)
Keep only the largest mutually consistent subset of edges (Pairwise Consistency Maximization,...
Parameters for wide-baseline loop-edge detection.
float pcm_trans_threshold
cycle translation tolerance (m)
float ransac_inlier_dist
3D-3D inlier threshold (m)
float min_depth
ignore keypoints with depth outside
unsigned seed
RANSAC determinism.
float max_depth
[min_depth, max_depth] (m)
int min_frame_gap
Skip pairs closer than this in frame index (those are handled by the temporal odometry factors,...
int exhaustive_budget
automatic uses exhaustive proposal when the temporally-distant pair count is at most this; above it,...
float min_seed_disagreement_fraction
Scale-relative part of the lower gate: a fraction of the seed trajectory's extent (issue #339).
LoopProposal proposal
Candidate-proposal strategy (see LoopProposal). Default automatic.
float max_candidate_distance
Propose a pair only if the seed camera centres are within this distance.
float min_sigma_trans
floor on the translational std (m)
float min_seed_disagreement
LOWER gate: keep a loop edge only if its relative translation disagrees with the seed poses by at lea...
bool pcm
Keep only the largest MUTUALLY CONSISTENT set of loop edges (Mangelson et al., PCM).
float base_sigma_trans
Base translational/rotational std at min_match_inliers; both shrink as sqrt(min_match_inliers/inliers...
float min_sigma_rot
floor on the rotational std (rad)
int pcm_max_edges
Cap the edge set fed to the O(M^2) consistency test (keep the highest- inlier edges).
float pcm_rot_threshold
cycle rotation tolerance (rad, ~8.6 deg)
int vocab_sample_per_frame
descriptors sampled per frame for vocab
int ransac_iterations
RANSAC hypotheses.
int vocab_iterations
k-means (k-majority) refinement passes
int max_candidates_per_frame
Keep at most this many candidates per frame (nearest / most-similar first).
int vocab_size
visual words (binary k-means clusters)
float max_view_angle
…and their optical viewing directions agree within this angle.
int max_features
ORB features per frame.
float ratio_test
Lowe ratio threshold for descriptor match.
float max_seed_disagreement
Optional sanity gate: reject an edge whose relative translation disagrees with the seed poses by more...
int min_match_inliers
reject a pair below this many RANSAC inliers
bool enable
off by default; opt-in via rux optimize --loop-closure
Summary statistics from loop-edge detection.
int candidates
spatially proposed pairs that were matched
int total_inliers
summed RANSAC inliers across accepted edges
A metric relative-pose constraint between two frames, ready to become a gtsam::BetweenFactor<Pose3>.
Eigen::Matrix4d T_ij
relative pose pose(i)^-1 * pose(j)
int inliers
RANSAC inlier correspondences supporting it.
int j
second frame (index into the frames vector)
double sigma_trans
translational std of the edge (m)
double sigma_rot
rotational std of the edge (rad)
int i
first frame (index into the frames vector)