Skip to content

Commit 3a6725a

Browse files
committed
feat: Implement Birge-ratio auto-balance for cov2cov and prior weighting
1 parent 79c8fde commit 3a6725a

5 files changed

Lines changed: 220 additions & 6 deletions

File tree

agents.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ Tests use gtest. Each filter, matcher, solver, and serializer has its own test f
199199
- Parameters follow the `Parameterizable` interface: `initialize(mrpt::containers::yaml)`
200200
- Always use braces `{}` for all `if`/`for`/`while` blocks
201201
- Coordinate frame naming: `T_A_to_B` = pose of {B} as seen from {A}; `composePoint` transforms FROM the local (B) frame TO the reference (A) frame
202+
- Don't use long hyphens. Use American spelling.
202203

203204
---
204205

mp2p_icp/include/mp2p_icp/optimal_tf_gauss_newton.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,22 @@ struct OptimalTF_GN_Parameters
5858
RobustKernel kernel = RobustKernel::None;
5959
double kernelParam = 1.0;
6060

61+
/** Generalized (tempered) Bayesian scaling for the cov-to-cov data block:
62+
* the cov2cov contribution to H and g is multiplied by `cov2cov_alpha`.
63+
* α=1 keeps the standard MAP cost; α<1 down-weights the data likelihood
64+
* (e.g. α=1/N turns it into a "mean residual" form). Useful when many
65+
* cov-to-cov pairings carry correlated information that the per-pair
66+
* modeled covariances do not capture, and the prior is otherwise drowned. */
67+
double cov2cov_alpha = 1.0;
68+
69+
/** If true (default) and a prior is provided, automatically balance the
70+
* cov2cov data block against the prior using a Birge-ratio-style global
71+
* scale of the modeled per-pair covariances:
72+
* κ = max(1, χ²_cc / (3·N_cc − 6))
73+
* evaluated at each iteration's current residuals; the cov2cov block of
74+
* H and g is then scaled by 1/κ. Has no effect when the prior is absent. */
75+
bool cov2cov_auto_balance_with_prior = true;
76+
6177
bool verbose = false;
6278
};
6379

mp2p_icp/src/optimal_tf_gauss_newton.cpp

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ bool mp2p_icp::optimal_tf_gauss_newton(
198198
//
199199
// ============== cov-to-cov ===============
200200
//
201+
// Accumulate the cov2cov block separately so we can apply a global
202+
// scale (generalized-Bayes α and/or Birge-ratio κ) before adding to H/g.
203+
Eigen::Matrix<double, 6, 6> H_cc = Eigen::Matrix<double, 6, 6>::Zero();
204+
Eigen::Matrix<double, 6, 1> g_cc = Eigen::Matrix<double, 6, 1>::Zero();
205+
double chi2_cc = 0; // sum of Mahalanobis squared norms
201206
#if defined(MP2P_HAS_TBB)
202207
const auto [H_tbb_cov2cov, g_tbb_cov2cov, errNormSqr_tbb_cov2cov] = tbb::parallel_reduce(
203208
// Range
@@ -243,9 +248,9 @@ bool mp2p_icp::optimal_tf_gauss_newton(
243248
// 2nd lambda: Parallel reduction
244249
[](const Result& a, const Result& b) -> Result { return a + b; });
245250

246-
H.noalias() += H_tbb_cov2cov;
247-
g.noalias() += g_tbb_cov2cov;
248-
errNormSqr += errNormSqr_tbb_cov2cov;
251+
H_cc = H_tbb_cov2cov;
252+
g_cc = g_tbb_cov2cov;
253+
chi2_cc = errNormSqr_tbb_cov2cov;
249254
#else
250255
// Cov-to-cov:
251256
for (size_t idx_pairing = 0; idx_pairing < nCov2Cov; idx_pairing++)
@@ -270,15 +275,32 @@ bool mp2p_icp::optimal_tf_gauss_newton(
270275

271276
// Error and Jacobian:
272277
const Eigen::Vector3d err_i = ret.asEigen();
273-
errNormSqr += weight * retSqrNorm;
278+
chi2_cc += weight * retSqrNorm;
274279

275280
const Eigen::Matrix<double, 3, 6> Ji = J1.asEigen() * dDexpe_de.asEigen();
276281

277282
// Whitening: multiply by Σ^{-1/2}
278-
g.noalias() += weight * Ji.transpose() * cov_inv * err_i;
279-
H.noalias() += weight * Ji.transpose() * cov_inv * Ji;
283+
g_cc.noalias() += weight * Ji.transpose() * cov_inv * err_i;
284+
H_cc.noalias() += weight * Ji.transpose() * cov_inv * Ji;
280285
}
281286
#endif
287+
// Generalized-Bayes / Birge-ratio scaling of the cov2cov data block,
288+
// to keep a meaningful balance against the prior when many cov2cov
289+
// pairings carry correlated information not captured by their per-pair
290+
// modeled covariances. See OptimalTF_GN_Parameters docs.
291+
{
292+
double cc_scale = gnParams.cov2cov_alpha;
293+
if (gnParams.cov2cov_auto_balance_with_prior && gnParams.prior.has_value() &&
294+
nCov2Cov >= 3)
295+
{
296+
const double dof = std::max(1.0, 3.0 * static_cast<double>(nCov2Cov) - 6.0);
297+
const double kappa = std::max(1.0, chi2_cc / dof);
298+
cc_scale /= kappa;
299+
}
300+
H.noalias() += cc_scale * H_cc;
301+
g.noalias() += cc_scale * g_cc;
302+
errNormSqr += cc_scale * chi2_cc;
303+
}
282304
//
283305
// ============== Point-to-line ===============
284306
//

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ mp2p_add_test(mp2p_matcher_pt2pt_parameterizable)
6060
mp2p_add_test(mp2p_matcher_pt2pt)
6161
mp2p_add_test(mp2p_optimal_tf_algos)
6262
mp2p_add_test(mp2p_optimize_cov2cov)
63+
mp2p_add_test(mp2p_optimize_cov2cov_with_prior)
6364
mp2p_add_test(mp2p_censi3d_covariance)
6465
mp2p_add_test(mp2p_pipeline_from_yaml)
6566
mp2p_add_test(mp2p_optimize_pt2ln)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/* _
2+
_ __ ___ ___ | | __ _
3+
| '_ ` _ \ / _ \| |/ _` | Modular Optimization framework for
4+
| | | | | | (_) | | (_| | Localization and mApping (MOLA)
5+
|_| |_| |_|\___/|_|\__,_| https://github.com/MOLAorg/mola
6+
7+
A repertory of multi primitive-to-primitive (MP2P) ICP algorithms
8+
and map building tools. mp2p_icp is part of MOLA.
9+
10+
Copyright (C) 2018-2026 Jose Luis Blanco, University of Almeria,
11+
and individual contributors.
12+
SPDX-License-Identifier: BSD-3-Clause
13+
*/
14+
15+
/**
16+
* @file test-mp2p_optimize_cov2cov_with_prior.cpp
17+
* @brief Verifies that, when many cov2cov pairings are combined with a
18+
* pose prior, the prior is not drowned by the data block thanks to
19+
* the generalized-Bayes alpha and the Birge-ratio auto-balance.
20+
* @author Jose Luis Blanco Claraco
21+
*/
22+
23+
#include <mp2p_icp/Pairings.h>
24+
#include <mp2p_icp/Results.h>
25+
#include <mp2p_icp/optimal_tf_gauss_newton.h>
26+
#include <mrpt/poses/Lie/SO.h>
27+
28+
#include <random>
29+
30+
namespace
31+
{
32+
// Build a set of cov2cov pairings consistent (up to noise) with `gtPose`,
33+
// scattered around the origin. Each pairing has a slightly inflated, isotropic
34+
// inverse covariance so that no single pairing fixes a degenerate direction.
35+
mp2p_icp::Pairings makeCov2CovPairings(const mrpt::poses::CPose3D& gtPose, std::size_t N)
36+
{
37+
std::mt19937 rng(42);
38+
std::uniform_real_distribution<float> u(-5.0f, 5.0f);
39+
// Actual point-pair noise is large (σ ≈ 0.3 m), but the *modeled*
40+
// per-pair information below pretends σ ≈ 1 cm — i.e. the per-pair
41+
// covariances are heavily overconfident, mimicking the realistic case
42+
// where neighbouring cov2cov pairings see correlated surface noise that
43+
// their independent Gaussians cannot capture.
44+
std::normal_distribution<float> noise(0.0f, 0.3f);
45+
46+
mp2p_icp::Pairings out;
47+
out.paired_cov2cov.reserve(N);
48+
for (std::size_t i = 0; i < N; i++)
49+
{
50+
auto& p = out.paired_cov2cov.emplace_back();
51+
p.local = {u(rng), u(rng), u(rng)};
52+
const auto pg = gtPose.composePoint(
53+
{p.local.x + noise(rng), p.local.y + noise(rng), p.local.z + noise(rng)});
54+
p.global = {static_cast<float>(pg.x), static_cast<float>(pg.y), static_cast<float>(pg.z)};
55+
// Overconfident modeled per-pair information: σ ≈ 1 cm isotropic.
56+
p.cov_inv.setDiagonal(std::vector<float>({1e4f, 1e4f, 1e4f}));
57+
}
58+
return out;
59+
}
60+
} // namespace
61+
62+
int main([[maybe_unused]] int argc, [[maybe_unused]] char** argv)
63+
{
64+
using mrpt::literals::operator""_deg;
65+
66+
try
67+
{
68+
// Ground-truth pose, and a *biased* prior shifted from it. The data
69+
// covers the GT, so an unbalanced solver will ignore the prior; a
70+
// properly balanced one will pull the estimate towards the prior.
71+
const auto gtPose =
72+
mrpt::poses::CPose3D::FromXYZYawPitchRoll(1.0, 0.0, 0.0, 0.0_deg, 0.0_deg, 0.0_deg);
73+
const auto priorMean =
74+
mrpt::poses::CPose3D::FromXYZYawPitchRoll(1.5, 0.0, 0.0, 0.0_deg, 0.0_deg, 0.0_deg);
75+
76+
// Many pairings, so the data block is O(N) larger than the prior.
77+
const std::size_t N = 400;
78+
const auto pairings = makeCov2CovPairings(gtPose, N);
79+
80+
// Reasonably tight prior (info = 1e4 → σ ≈ 1 cm).
81+
mrpt::poses::CPose3DPDFGaussianInf prior;
82+
prior.mean = priorMean;
83+
prior.cov_inv.setZero();
84+
for (int i = 0; i < 6; i++) prior.cov_inv(i, i) = 1e4;
85+
86+
const auto initPose = priorMean;
87+
88+
// Case A: balancing DISABLED (alpha=1, auto-balance off) → data wins.
89+
mrpt::poses::CPose3D poseNoBalance;
90+
{
91+
mp2p_icp::OptimalTF_GN_Parameters gnParams;
92+
gnParams.linearizationPoint = initPose;
93+
gnParams.prior = prior;
94+
gnParams.cov2cov_alpha = 1.0;
95+
gnParams.cov2cov_auto_balance_with_prior = false;
96+
gnParams.maxInnerLoopIterations = 20;
97+
98+
mp2p_icp::OptimalTF_Result result;
99+
ASSERT_(mp2p_icp::optimal_tf_gauss_newton(pairings, result, gnParams));
100+
poseNoBalance = result.optimalPose;
101+
std::cout << "[no-balance] pose: " << poseNoBalance << "\n";
102+
}
103+
104+
// Case B: auto-balance ENABLED (default) → prior is respected.
105+
mrpt::poses::CPose3D poseBalanced;
106+
{
107+
mp2p_icp::OptimalTF_GN_Parameters gnParams; // defaults: balance ON
108+
gnParams.linearizationPoint = initPose;
109+
gnParams.prior = prior;
110+
gnParams.maxInnerLoopIterations = 20;
111+
112+
mp2p_icp::OptimalTF_Result result;
113+
ASSERT_(mp2p_icp::optimal_tf_gauss_newton(pairings, result, gnParams));
114+
poseBalanced = result.optimalPose;
115+
std::cout << "[balanced ] pose: " << poseBalanced << "\n";
116+
}
117+
118+
const double dxNoBalance = std::abs(poseNoBalance.x() - priorMean.x());
119+
const double dxBalanced = std::abs(poseBalanced.x() - priorMean.x());
120+
std::cout << "|x - x_prior| no-balance=" << dxNoBalance << " balanced=" << dxBalanced
121+
<< "\n";
122+
123+
// Without balancing, the solver should snap to the data (≈ gtPose).
124+
ASSERT_LT_(std::abs(poseNoBalance.x() - gtPose.x()), 0.05);
125+
126+
// With balancing, the result must be measurably pulled back toward
127+
// the prior compared with the unbalanced case.
128+
ASSERT_LT_(dxBalanced, dxNoBalance - 0.05);
129+
130+
// And it must remain a sensible compromise between prior and data,
131+
// i.e. located between them along x (within tolerances).
132+
ASSERT_GT_(poseBalanced.x(), gtPose.x() - 0.05);
133+
ASSERT_LT_(poseBalanced.x(), priorMean.x() + 0.05);
134+
135+
// Sanity: with α=1 and balancing off, alpha=1/N should also recover a
136+
// prior-respecting result (manual generalized-Bayes path).
137+
{
138+
mp2p_icp::OptimalTF_GN_Parameters gnParams;
139+
gnParams.linearizationPoint = initPose;
140+
gnParams.prior = prior;
141+
gnParams.cov2cov_alpha = 1.0 / static_cast<double>(N);
142+
gnParams.cov2cov_auto_balance_with_prior = false;
143+
gnParams.maxInnerLoopIterations = 20;
144+
145+
mp2p_icp::OptimalTF_Result result;
146+
ASSERT_(mp2p_icp::optimal_tf_gauss_newton(pairings, result, gnParams));
147+
std::cout << "[alpha=1/N] pose: " << result.optimalPose << "\n";
148+
ASSERT_LT_(std::abs(result.optimalPose.x() - priorMean.x()), dxNoBalance - 0.05);
149+
}
150+
151+
// No-prior regression: with no prior, balancing must be inert (κ
152+
// multiplier disabled), so the original cov2cov-only test setup
153+
// still converges to GT.
154+
{
155+
const auto initBad = mrpt::poses::CPose3D::FromXYZYawPitchRoll(
156+
0.0, 0.2, 0.1, 2.0_deg, -2.0_deg, -3.0_deg);
157+
158+
mp2p_icp::OptimalTF_GN_Parameters gnParams; // default balance ON
159+
gnParams.linearizationPoint = initBad;
160+
gnParams.maxInnerLoopIterations = 20;
161+
162+
mp2p_icp::OptimalTF_Result result;
163+
ASSERT_(mp2p_icp::optimal_tf_gauss_newton(pairings, result, gnParams));
164+
const auto poseError = gtPose - result.optimalPose;
165+
ASSERT_LT_(poseError.translation().norm(), 0.05);
166+
ASSERT_LT_(mrpt::poses::Lie::SO<3>::log(poseError.getRotationMatrix()).norm(), 0.05);
167+
}
168+
}
169+
catch (std::exception& e)
170+
{
171+
std::cerr << mrpt::exception_to_str(e) << "\n";
172+
return 1;
173+
}
174+
}

0 commit comments

Comments
 (0)