making some tests less flaky

This commit is contained in:
matlabbe
2026-05-24 22:39:04 -07:00
parent e9c0194546
commit 828a7ed100
5 changed files with 127 additions and 16 deletions
@@ -74,6 +74,34 @@ namespace util3d
*
* @see cv::solvePnPRansac
*/
/**
* @brief Toggle a deterministic seed for OpenGV's internal RANSAC RNG.
*
* OpenGV's @c SampleConsensusProblem (and its multi-camera sibling) seeds its
* internal @c std::mt19937 from the system clock when default-constructed,
* which makes every @ref estimateMotion3DTo2D() call non-reproducible across
* runs. Calling @c setRansacDeterministicSeed(true) reseeds OpenGV's RNG with
* the fixed value @c 12345 before each RANSAC pass so identical inputs always
* produce identical inlier sets, covariances and output transforms.
*
* Intended for tests; production code should leave this off (default).
*
* @param enable If true, force the deterministic seed; if false (default),
* use OpenGV's system-clock seed.
*
* @todo Expose this through the @c Parameters layer (e.g.
* @c kVisDeterministicRansacSeed) so production runs that need
* bit-for-bit replayability - reproducing a reported failure on the
* exact same input, or doing regression diffs across rtabmap versions -
* can opt in without code edits. Production should default to the
* wall-clock seed (occasional sample diversity still helps marginal
* inputs).
*/
void RTABMAP_CORE_EXPORT setRansacDeterministicSeed(bool enable);
/** @return Whether the deterministic-seed toggle is currently enabled. */
bool RTABMAP_CORE_EXPORT ransacDeterministicSeedEnabled();
Transform RTABMAP_CORE_EXPORT estimateMotion3DTo2D(
const std::map<int, cv::Point3f> & words3A,
const std::map<int, cv::KeyPoint> & words2B,
+38
View File
@@ -56,6 +56,24 @@ namespace rtabmap
namespace util3d
{
namespace {
// When true, every newly-constructed OpenGV SAC problem inside this
// translation unit gets its RNG reseeded with a fixed constant so that
// RANSAC is bit-for-bit reproducible. Tests can flip this on via
// setRansacDeterministicSeed(true); production code leaves it off.
bool g_ransacDeterministicSeed = false;
} // namespace
void setRansacDeterministicSeed(bool enable)
{
g_ransacDeterministicSeed = enable;
}
bool ransacDeterministicSeedEnabled()
{
return g_ransacDeterministicSeed;
}
Transform estimateMotion3DTo2D(
const std::map<int, cv::Point3f> & words3A,
const std::map<int, cv::KeyPoint> & words2B,
@@ -517,6 +535,18 @@ Transform estimateMotion3DTo2D(
std::shared_ptr<opengv::sac_problems::absolute_pose::MultiNoncentralAbsolutePoseSacProblem> absposeproblem_ptr(
new opengv::sac_problems::absolute_pose::MultiNoncentralAbsolutePoseSacProblem(adapter));
// Opt-in deterministic RANSAC: OpenGV's default constructor seeds
// its internal mt19937 from the system clock, so without this
// override two calls with identical inputs can produce different
// inlier sets / covariances. Tests flip the toggle via
// setRansacDeterministicSeed(true).
if(g_ransacDeterministicSeed)
{
absposeproblem_ptr->rng_alg_.seed(12345u);
absposeproblem_ptr->rng_gen_.reset(new std::function<int()>(
std::bind(*absposeproblem_ptr->rng_dist_, absposeproblem_ptr->rng_alg_)));
}
ransac.sac_model_ = absposeproblem_ptr;
ransac.threshold_ = 1.0 - cos(atan(reprojError/cameraModels[0].fx()));
ransac.max_iterations_ = iterations;
@@ -572,6 +602,14 @@ Transform estimateMotion3DTo2D(
std::shared_ptr<opengv::sac_problems::absolute_pose::AbsolutePoseSacProblem> absposeproblem_ptr(
new opengv::sac_problems::absolute_pose::AbsolutePoseSacProblem(adapter, opengv::sac_problems::absolute_pose::AbsolutePoseSacProblem::GP3P));
// Opt-in deterministic RANSAC (see comment on MultiRansac above).
if(g_ransacDeterministicSeed)
{
absposeproblem_ptr->rng_alg_.seed(12345u);
absposeproblem_ptr->rng_gen_.reset(new std::function<int()>(
std::bind(*absposeproblem_ptr->rng_dist_, absposeproblem_ptr->rng_alg_)));
}
ransac.sac_model_ = absposeproblem_ptr;
ransac.threshold_ = 1.0 - cos(atan(reprojError/cameraModels[0].fx()));
ransac.max_iterations_ = iterations;
+11 -9
View File
@@ -35,6 +35,17 @@ static ParametersMap registrationVisTestParams(int estimationType = 1, int corTy
params[Parameters::kVisCorType()] = std::to_string(corType); // 0=feature matching, 1=optical flow
params[Parameters::kVisBundleAdjustment()] = "0";
params[Parameters::kVisRoiRatios()] = kRoiRatios;
if(corType == 1)
{
// Lucas-Kanade tracker defaults (win=16, levels=3) leave enough
// sub-pixel drift to push most correspondences past the 2 px
// reprojection bound on some OpenCV builds (SIMD/IPP differences in
// cv::calcOpticalFlowPyrLK). A larger window + more pyramid levels
// converge tighter and keep the OF test robust across platforms
// (~90% inlier ratio instead of ~5% on Ubuntu's DFSG OpenCV).
params[Parameters::kVisCorFlowWinSize()] = "21";
params[Parameters::kVisCorFlowMaxLevel()] = "5";
}
return params;
}
@@ -134,15 +145,6 @@ static Transform computeRegistration(
return reg.computeTransformation(from, to, nullGuess, infoOut ? infoOut : &info);
}
static Transform computeRegistration(
const SensorData & fromData,
const SensorData & toData,
int estimationType,
RegistrationInfo * infoOut = nullptr)
{
return computeRegistration(fromData, toData, registrationVisTestParams(estimationType), infoOut);
}
// Golden transforms (GFTT/ORB, MinDistance=3, QualityLevel=0.01, MaxFeatures=3000, RoiRatios=0 0 0 0.3).
// Captured with Vis/CorType=0 (feature matching); also used for optical flow (CorType=1) within tolerance.
// Shared by FM/OF, Vis/BundleAdjustment=0 and g2o BA=1.
+47 -7
View File
@@ -1017,6 +1017,14 @@ TEST_F(RtabmapFixture, RejectLastLoopClosureRemovesLinkAndResetsHypothesis)
// so mapCorrection collapses back to identity.
ParametersMap params = defaultRtabmapParams();
params[Parameters::kRGBDOptimizeMaxError()] = "0";
// TORO's default convergence epsilon (1e-5) stops the gradient descent
// while ~18 mm of correction still hasn't unwound. Tighten it so all
// three backends converge close enough to identity to satisfy the
// post-check below. Iterations stay at the default (100): bumping them
// further would let TORO reach ~1 mm, but the test's 1 cm bound is
// already comfortably above the ~6.5 mm TORO hits at 100 iterations
// with this epsilon, so the cheaper iteration budget is enough.
params[Parameters::kOptimizerEpsilon()] = "1e-10";
reinit(params);
process();
const int N1 = rtabmap_->getLastLocationId();
@@ -1039,8 +1047,14 @@ TEST_F(RtabmapFixture, RejectLastLoopClosureRemovesLinkAndResetsHypothesis)
{
EXPECT_NE(kv.second.type(), Link::kUserClosure);
}
// Graph re-optimized without the rejected link -> mapCorrection identity.
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
// Graph re-optimized without the rejected link -> mapCorrection collapses
// back toward identity. The rejected loop disagreed with the odom chain
// by 1 m, so a 1 cm residual is 99% undone. (g2o / GTSAM hit zero; TORO
// is gradient-descent so its floor is non-zero - around 6.5 mm at the
// default 100 iterations with epsilon 1e-10. Transform::isIdentity() is
// bit-exact, so we check the norm instead.)
const Transform mc = rtabmap_->getMapCorrection();
EXPECT_LT(mc.getNorm(), 1e-2f) << "post-reject correction: " << mc.prettyPrint();
}
TEST_F(RtabmapFixture, RejectLastLoopClosureIsNoOpWhenNoLoopClosureExists)
@@ -1712,7 +1726,10 @@ TEST_F(RtabmapFixture, FollowLongPathWithIntermediateNodesRetrievesRealLtmNodes)
for(const auto & kv : rtabmap_->getPath())
{
const Signature * s = rtabmap_->getMemory()->getSignature(kv.first);
if(s) EXPECT_NE(s->getWeight(), -1) << "intermediate id=" << kv.first << " on path";
if(s)
{
EXPECT_NE(s->getWeight(), -1) << "intermediate id=" << kv.first << " on path";
}
}
// Walk back along the path. Path-follow advances and eventually reaches N1.
@@ -2918,8 +2935,12 @@ TEST(RtabmapTest, GlobalBundleAdjustmentRefinesPosesOnSynthScene)
/*align2D=*/false);
return std::make_pair(t_rmse, r_rmse);
};
const auto [tRmseBefore, rRmseBefore] = rmse(before);
const auto [tRmseAfter, rRmseAfter] = rmse(after);
const auto rmseBefore = rmse(before);
const auto rmseAfter = rmse(after);
const float tRmseBefore = rmseBefore.first;
const float rRmseBefore = rmseBefore.second;
const float tRmseAfter = rmseAfter.first;
const float rRmseAfter = rmseAfter.second;
// BA must reduce both translational and rotational RMSE toward GT, with
// the residual bounded by the measurement noise floor.
EXPECT_LT(tRmseAfter, tRmseBefore) << "BA must reduce translational RMSE";
@@ -3214,7 +3235,22 @@ TEST(RtabmapTest, LandmarkObservationsAcrossFramesShareSameLandmarkPose)
// Two frames both observe landmark id=42 at the same world location.
// Memory stores the landmark once (key=-42 in the graph) and links both
// frames to it.
//
// The default optimizer is built-dependent: GTSAM and g2o include the
// landmark as a graph variable; TORO ignores landmark constraints and
// won't expose -kLm in the optimized poses. Force a backend that
// supports landmarks; skip if none is available in this build.
int optimizerStrategy = -1;
if(Optimizer::isAvailable(Optimizer::kTypeGTSAM)) optimizerStrategy = Optimizer::kTypeGTSAM;
else if(Optimizer::isAvailable(Optimizer::kTypeG2O)) optimizerStrategy = Optimizer::kTypeG2O;
if(optimizerStrategy < 0)
{
GTEST_SKIP() << "neither GTSAM nor g2o is available; the default optimizer "
"(TORO/Ceres) does not include landmarks in the optimized graph";
}
ParametersMap params = defaultRtabmapParams();
params[Parameters::kOptimizerStrategy()] = uNumber2Str(optimizerStrategy);
params[Parameters::kOptimizerLandmarksIgnored()] = "false";
Rtabmap rtabmap;
rtabmap.init(params);
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
@@ -3657,8 +3693,12 @@ TEST(RtabmapTest, AggressiveLoopThresholdAcceptsBelowPrimaryThreshold)
return std::make_pair(id, high);
};
const auto [idAgg, highAgg] = runOnce(kAggressiveThr);
const auto [idPrim, highPrim] = runOnce(kLoopThr); // aggressive disabled (= primary)
const auto resultAgg = runOnce(kAggressiveThr);
const auto resultPrim = runOnce(kLoopThr); // aggressive disabled (= primary)
const int idAgg = resultAgg.first;
const float highAgg = resultAgg.second;
const int idPrim = resultPrim.first;
const float highPrim = resultPrim.second;
// Sanity: both invocations see the same Bayes peak (deterministic data).
EXPECT_NEAR(highAgg, highPrim, 1e-3);
@@ -243,6 +243,9 @@ TEST(Util3dMotionEstimationTest, EstimateMotion3DTo2DWithNoise) {
}
TEST(Util3dMotionEstimationTest, EstimateMotion3DTo2DMultiCamBasic) {
// OpenGV's RANSAC RNG defaults to a wall-clock seed, which makes the
// covariance / inlier outputs jitter across runs. Pin it for the test.
util3d::setRansacDeterministicSeed(true);
// Two triangles in front of the camera at two different depths, centered with the middle of the image frame
std::map<int, cv::Point3f> words3A = {