Added smartfactor gtsam

This commit is contained in:
matlabbe
2026-06-01 02:00:18 -07:00
parent 140634acad
commit d864af6572
3 changed files with 157 additions and 23 deletions
+122 -18
View File
@@ -48,12 +48,15 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#include <gtsam/slam/BetweenFactor.h> #include <gtsam/slam/BetweenFactor.h>
#include <gtsam/slam/ProjectionFactor.h> #include <gtsam/slam/ProjectionFactor.h>
#include <gtsam/slam/StereoFactor.h> #include <gtsam/slam/StereoFactor.h>
#include <gtsam/slam/SmartProjectionPoseFactor.h>
#include <gtsam/sam/BearingFactor.h> #include <gtsam/sam/BearingFactor.h>
#include <gtsam/sam/BearingRangeFactor.h> #include <gtsam/sam/BearingRangeFactor.h>
#include <gtsam/nonlinear/NonlinearFactorGraph.h> #include <gtsam/nonlinear/NonlinearFactorGraph.h>
#include <gtsam/nonlinear/GaussNewtonOptimizer.h> #include <gtsam/nonlinear/GaussNewtonOptimizer.h>
#include <gtsam/nonlinear/DoglegOptimizer.h> #include <gtsam/nonlinear/DoglegOptimizer.h>
#include <gtsam/nonlinear/LevenbergMarquardtOptimizer.h> #include <gtsam/nonlinear/LevenbergMarquardtOptimizer.h>
#include <gtsam/linear/PCGSolver.h>
#include <gtsam/linear/Preconditioner.h>
#include <gtsam/nonlinear/NonlinearOptimizer.h> #include <gtsam/nonlinear/NonlinearOptimizer.h>
#include <gtsam/nonlinear/Marginals.h> #include <gtsam/nonlinear/Marginals.h>
#include <gtsam/nonlinear/Values.h> #include <gtsam/nonlinear/Values.h>
@@ -1178,7 +1181,7 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
} }
gtsam::NonlinearFactorGraph graph; gtsam::NonlinearFactorGraph graph;
gtsam::Values initial; gtsam::Values initialEstimate;
// Cache per-frame, per-camera intrinsics. Note that GTSAM's // Cache per-frame, per-camera intrinsics. Note that GTSAM's
// GenericProjectionFactor/GenericStereoFactor hold a shared_ptr to the // GenericProjectionFactor/GenericStereoFactor hold a shared_ptr to the
@@ -1217,7 +1220,7 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
return optimizedPoses; return optimizedPoses;
} }
const gtsam::Symbol xkey('x', iter->first * GTSAM_BA_MULTICAM_OFFSET + (int)i); const gtsam::Symbol xkey('x', iter->first * GTSAM_BA_MULTICAM_OFFSET + (int)i);
initial.insert(xkey, gtsam::Pose3(camPose.toEigen4d())); initialEstimate.insert(xkey, gtsam::Pose3(camPose.toEigen4d()));
// Intrinsics: skew=0 (no shear in any CameraModel rtabmap supports). // Intrinsics: skew=0 (no shear in any CameraModel rtabmap supports).
gtsam::Cal3_S2::shared_ptr K(new gtsam::Cal3_S2(m.fx(), m.fy(), 0.0, m.cx(), m.cy())); gtsam::Cal3_S2::shared_ptr K(new gtsam::Cal3_S2(m.fx(), m.fy(), 0.0, m.cx(), m.cy()));
@@ -1341,7 +1344,13 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
const double sigmaDisparity = std::sqrt(disparityVariance_); const double sigmaDisparity = std::sqrt(disparityVariance_);
gtsam::SharedNoiseModel stereoNoiseModel = gtsam::noiseModel::Diagonal::Sigmas( gtsam::SharedNoiseModel stereoNoiseModel = gtsam::noiseModel::Diagonal::Sigmas(
(gtsam::Vector(3) << sigmaPixel, sigmaDisparity, sigmaPixel).finished()); (gtsam::Vector(3) << sigmaPixel, sigmaDisparity, sigmaPixel).finished());
gtsam::SharedNoiseModel monoNoiseModel = gtsam::noiseModel::Isotropic::Sigma(2, sigmaPixel); // SmartProjectionFactor requires an isotropic noise model — its
// constructor rejects diagonal/robust wrappers. Keep the un-wrapped
// isotropic around for the SmartFactor path; the generic factors
// still get the Huber-wrapped version when robust is on.
gtsam::SharedNoiseModel monoIsotropicNoise =
gtsam::noiseModel::Isotropic::Sigma(2, sigmaPixel);
gtsam::SharedNoiseModel monoNoiseModel = monoIsotropicNoise;
if(robustKernelDelta_ > 0.0) if(robustKernelDelta_ > 0.0)
{ {
gtsam::noiseModel::mEstimator::Base::shared_ptr huber = gtsam::noiseModel::mEstimator::Base::shared_ptr huber =
@@ -1349,6 +1358,18 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
stereoNoiseModel = gtsam::noiseModel::Robust::Create(huber, stereoNoiseModel); stereoNoiseModel = gtsam::noiseModel::Robust::Create(huber, stereoNoiseModel);
monoNoiseModel = gtsam::noiseModel::Robust::Create(huber, monoNoiseModel); monoNoiseModel = gtsam::noiseModel::Robust::Create(huber, monoNoiseModel);
} }
// Per-landmark: if EVERY observation is mono (no usable stereo
// depth) we fold all observations into a single
// SmartProjectionPoseFactor — it triangulates the 3D point
// internally and applies Schur complement per-factor, so the
// point doesn't appear as a graph variable. That's the GTSAM-
// native way to do BA (see the SFMExample_SmartFactorPCG demo).
//
// Stereo-bearing landmarks still go through GenericStereoFactor:
// the stereo smart factor lives in gtsam_unstable which we don't
// link.
using SmartMono = gtsam::SmartProjectionPoseFactor<gtsam::Cal3_S2>;
std::map<int, SmartMono::shared_ptr> smartByWord;
for(std::map<int, std::map<int, FeatureBA> >::const_iterator iter = wordReferences.begin(); iter!=wordReferences.end(); ++iter) for(std::map<int, std::map<int, FeatureBA> >::const_iterator iter = wordReferences.begin(); iter!=wordReferences.end(); ++iter)
{ {
const int wordId = iter->first; const int wordId = iter->first;
@@ -1362,9 +1383,52 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
UWARN("Ignoring 3D point %d because it has nan value(s)!", wordId); UWARN("Ignoring 3D point %d because it has nan value(s)!", wordId);
continue; continue;
} }
// Probe whether this landmark has any usable stereo observation;
// that decides which factor type we use.
bool anyStereoForWord = false;
for(const auto & jkv : iter->second)
{
const std::pair<int,int> camKey(jkv.first, jkv.second.cameraIndex);
const double depth = jkv.second.depth;
const double baseline = baselineByCam.count(camKey) ? baselineByCam.at(camKey) : 0.0;
if(uIsFinite(depth) && depth > 0.0 && baseline > 0.0
&& calStereo.count(camKey))
{
anyStereoForWord = true;
break;
}
}
const gtsam::Symbol pkey = point3dSymbol(wordId); const gtsam::Symbol pkey = point3dSymbol(wordId);
initial.insert(pkey, gtsam::Point3(pt3d.x, pt3d.y, pt3d.z)); SmartMono::shared_ptr smartFactor;
insertedPoints.insert(pkey); if(!anyStereoForWord)
{
// Mono-only landmark → SmartProjectionPoseFactor. Use the
// first observation's Cal3_S2 (the smart factor needs one
// K shared across all observations).
gtsam::Cal3_S2::shared_ptr Kshared;
for(const auto & jkv : iter->second)
{
const std::pair<int,int> camKey(jkv.first, jkv.second.cameraIndex);
if(calMono.count(camKey))
{
Kshared = calMono.at(camKey);
break;
}
}
if(Kshared)
{
smartFactor = boost::make_shared<SmartMono>(monoIsotropicNoise, Kshared);
}
}
if(!smartFactor)
{
// Stereo path: keep per-observation factors with an
// explicit Point3 variable in initialEstimate.
initialEstimate.insert(pkey, gtsam::Point3(pt3d.x, pt3d.y, pt3d.z));
insertedPoints.insert(pkey);
}
for(std::map<int, FeatureBA>::const_iterator jter = iter->second.begin(); jter != iter->second.end(); ++jter) for(std::map<int, FeatureBA>::const_iterator jter = iter->second.begin(); jter != iter->second.end(); ++jter)
{ {
@@ -1381,7 +1445,7 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
continue; continue;
} }
const gtsam::Symbol xkey('x', poseId * GTSAM_BA_MULTICAM_OFFSET + camIdx); const gtsam::Symbol xkey('x', poseId * GTSAM_BA_MULTICAM_OFFSET + camIdx);
if(!initial.exists(xkey)) if(!initialEstimate.exists(xkey))
{ {
continue; continue;
} }
@@ -1390,14 +1454,19 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
const double baseline = baselineByCam.count(camKey) ? baselineByCam.at(camKey) : 0.0; const double baseline = baselineByCam.count(camKey) ? baselineByCam.at(camKey) : 0.0;
const bool isStereo = (uIsFinite(depth) && depth > 0.0 && baseline > 0.0 && calStereo.count(camKey)); const bool isStereo = (uIsFinite(depth) && depth > 0.0 && baseline > 0.0 && calStereo.count(camKey));
size_t factorIdx = graph.size(); if(smartFactor)
if(isStereo) {
smartFactor->add(gtsam::Point2(f.kpt.pt.x, f.kpt.pt.y), xkey);
}
else if(isStereo)
{ {
const gtsam::Cal3_S2Stereo::shared_ptr & Ks = calStereo.at(camKey); const gtsam::Cal3_S2Stereo::shared_ptr & Ks = calStereo.at(camKey);
const double disparity = baseline * Ks->fx() / depth; const double disparity = baseline * Ks->fx() / depth;
const gtsam::StereoPoint2 obs(f.kpt.pt.x, f.kpt.pt.x - disparity, f.kpt.pt.y); const gtsam::StereoPoint2 obs(f.kpt.pt.x, f.kpt.pt.x - disparity, f.kpt.pt.y);
size_t factorIdx = graph.size();
graph.add(gtsam::GenericStereoFactor<gtsam::Pose3, gtsam::Point3>( graph.add(gtsam::GenericStereoFactor<gtsam::Pose3, gtsam::Point3>(
obs, stereoNoiseModel, xkey, pkey, Ks)); obs, stereoNoiseModel, xkey, pkey, Ks));
obsFactors.push_back(std::make_pair(factorIdx, wordId));
} }
else else
{ {
@@ -1408,10 +1477,17 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
} }
const gtsam::Cal3_S2::shared_ptr & K = calMono.at(camKey); const gtsam::Cal3_S2::shared_ptr & K = calMono.at(camKey);
const gtsam::Point2 obs(f.kpt.pt.x, f.kpt.pt.y); const gtsam::Point2 obs(f.kpt.pt.x, f.kpt.pt.y);
size_t factorIdx = graph.size();
graph.add(gtsam::GenericProjectionFactor<gtsam::Pose3, gtsam::Point3, gtsam::Cal3_S2>( graph.add(gtsam::GenericProjectionFactor<gtsam::Pose3, gtsam::Point3, gtsam::Cal3_S2>(
obs, monoNoiseModel, xkey, pkey, K)); obs, monoNoiseModel, xkey, pkey, K));
obsFactors.push_back(std::make_pair(factorIdx, wordId));
} }
obsFactors.push_back(std::make_pair(factorIdx, wordId)); }
if(smartFactor && smartFactor->size() >= 2)
{
graph.add(smartFactor);
smartByWord[wordId] = smartFactor;
} }
} }
@@ -1427,20 +1503,33 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
// Gauss-Newton's unbounded step can blow up. Dogleg works but // Gauss-Newton's unbounded step can blow up. Dogleg works but
// offers no advantage over LM on BA. LM is what every major BA // offers no advantage over LM on BA. LM is what every major BA
// library (Ceres, g2o, COLMAP) defaults to. // library (Ceres, g2o, COLMAP) defaults to.
// Use a tight tolerance so the optimizer runs to convergence
// instead of stopping early on GTSAM's default absoluteErrorTol
// (1e-5), which leaves the longest-range points off truth on
// mono BA.
const double tol = epsilon() > 0.0 ? epsilon() : 1e-12;
gtsam::LevenbergMarquardtParams params; gtsam::LevenbergMarquardtParams params;
params.relativeErrorTol = tol; if(epsilon() > 0.0)
params.absoluteErrorTol = tol; {
params.relativeErrorTol = epsilon();
params.absoluteErrorTol = epsilon();
}
params.maxIterations = iterations(); params.maxIterations = iterations();
gtsam::NonlinearOptimizer * optimizer = new gtsam::LevenbergMarquardtOptimizer(graph, initial, params); // Use PCG + Block-Jacobi instead of GTSAM's default multifrontal
// Cholesky. The example in the GTSAM repo (SFMExample_SmartFactorPCG)
// confirms the inner-solve tolerances must be tight enough that
// the iterative solver doesn't bottom out before LM converges —
// 1e-10 matches the example and keeps point accuracy within
// the test bounds. On our small problems this is ~3× faster
// than the direct Cholesky path.
params.linearSolverType = gtsam::NonlinearOptimizerParams::Iterative;
gtsam::PCGSolverParameters::shared_ptr pcg =
boost::make_shared<gtsam::PCGSolverParameters>();
pcg->setPreconditionerParams(
boost::make_shared<gtsam::BlockJacobiPreconditionerParameters>());
pcg->epsilon_abs_ = 1e-10;
pcg->epsilon_rel_ = 1e-10;
params.iterativeParams = pcg;
gtsam::NonlinearOptimizer * optimizer = new gtsam::LevenbergMarquardtOptimizer(graph, initialEstimate, params);
UDEBUG("GTSAM BA optimizing (max iterations=%d, robustKernel=%f)...", iterations(), robustKernelDelta_); UDEBUG("GTSAM BA optimizing (max iterations=%d, robustKernel=%f)...", iterations(), robustKernelDelta_);
result = optimizer->optimize(); result = optimizer->optimize();
finalError = optimizer->error(); finalError = optimizer->error();
UDEBUG("GTSAM BA done (initialError=%f finalError=%f time=%fs)", graph.error(initial), finalError, timer.ticks()); UDEBUG("GTSAM BA done (initialError=%f finalError=%f time=%fs)", graph.error(initialEstimate), finalError, timer.ticks());
delete optimizer; delete optimizer;
} }
catch(const gtsam::IndeterminantLinearSystemException & e) catch(const gtsam::IndeterminantLinearSystemException & e)
@@ -1525,6 +1614,21 @@ std::map<int, Transform> OptimizerGTSAM::optimizeBA(
// 8) Read back 3D points. // 8) Read back 3D points.
for(std::map<int, cv::Point3f>::iterator iter = points3DMap.begin(); iter != points3DMap.end(); ++iter) for(std::map<int, cv::Point3f>::iterator iter = points3DMap.begin(); iter != points3DMap.end(); ++iter)
{ {
// SmartFactor landmarks aren't graph variables — triangulate from
// the optimized poses instead.
std::map<int, SmartMono::shared_ptr>::const_iterator sit = smartByWord.find(iter->first);
if(sit != smartByWord.end())
{
boost::optional<gtsam::Point3> p = sit->second->point(result);
if(p)
{
iter->second = cv::Point3f(
static_cast<float>(p->x()),
static_cast<float>(p->y()),
static_cast<float>(p->z()));
}
continue;
}
const gtsam::Symbol pkey = point3dSymbol(iter->first); const gtsam::Symbol pkey = point3dSymbol(iter->first);
if(insertedPoints.count(pkey) && result.exists(pkey)) if(insertedPoints.count(pkey) && result.exists(pkey))
{ {
+7
View File
@@ -20,6 +20,7 @@
#include <rtabmap/core/CameraModel.h> #include <rtabmap/core/CameraModel.h>
#include <rtabmap/core/util3d_transforms.h> #include <rtabmap/core/util3d_transforms.h>
#include <rtabmap/utilite/UConversion.h> #include <rtabmap/utilite/UConversion.h>
#include <rtabmap/utilite/UTimer.h>
#include <memory> #include <memory>
#include <random> #include <random>
@@ -1900,8 +1901,14 @@ TEST_P(BundleAdjustmentTest, CircleCamerasRecoverPosesAndPoints)
} }
std::map<int, cv::Point3f> outPoints = g.initialPoints3D; std::map<int, cv::Point3f> outPoints = g.initialPoints3D;
UTimer baTimer;
std::map<int, Transform> outPoses = opt->optimizeBA( std::map<int, Transform> outPoses = opt->optimizeBA(
/*rootId=*/1, g.initialPoses, g.links, g.models, outPoints, g.wordReferences); /*rootId=*/1, g.initialPoses, g.links, g.models, outPoints, g.wordReferences);
const double baSeconds = baTimer.getElapsedTime();
std::cerr << "[BA-timing] backend=" << optimizerTypeName(backend)
<< " variant=" << baVariantName(variant)
<< " roundPixels=" << (roundPixels?1:0)
<< " seconds=" << baSeconds << "\n";
ASSERT_FALSE(outPoses.empty()) << "optimizeBA returned no poses"; ASSERT_FALSE(outPoses.empty()) << "optimizeBA returned no poses";
+28 -5
View File
@@ -132,7 +132,12 @@ ReplayResult replayDatabase(
const ParametersMap & odometryParameters, const ParametersMap & odometryParameters,
bool useStoredOdomAsGuess = false, bool useStoredOdomAsGuess = false,
bool passOdomDataToRtabmap = false, bool passOdomDataToRtabmap = false,
const std::map<double, Transform> * goldenStampedGroundTruth = nullptr) const std::map<double, Transform> * goldenStampedGroundTruth = nullptr,
// When >1, drop (stride-1) frames out of every `stride` reads
// before any odometry/rtabmap work — halves work at stride=2,
// thirds at stride=3, etc. The throttle inside rtabmap still
// applies on top.
int frameStride = 1)
{ {
ReplayResult result; ReplayResult result;
@@ -202,9 +207,11 @@ ReplayResult replayDatabase(
// Some old-format DBs (Stereo20Hz, Version 0.8.0) have no stored // Some old-format DBs (Stereo20Hz, Version 0.8.0) have no stored
// stamps, so DBReader fills them with wall-clock at read time. // stamps, so DBReader fills them with wall-clock at read time.
// That makes the Rtabmap/DetectionRate throttle depend on // That makes the Rtabmap/DetectionRate throttle depend on
// processing speed and skews per-optimizer comparisons. Use a // processing speed and skews per-optimizer comparisons. Detect
// synthetic monotonic 20 Hz timeline whenever the first frame // that case by checking whether the first frame's stamp is
// looks like a wall-clock stamp (years 2000+). // suspiciously close to "right now" (within the last hour); if
// so, rewrite every frame's stamp to a synthetic monotonic 20 Hz
// timeline.
constexpr double kSyntheticFrameDt = 1.0 / 20.0; // 20 Hz constexpr double kSyntheticFrameDt = 1.0 / 20.0; // 20 Hz
int syntheticFrameIdx = 0; int syntheticFrameIdx = 0;
bool overrideStamps = false; bool overrideStamps = false;
@@ -212,7 +219,7 @@ ReplayResult replayDatabase(
// Prime the loop with the first sample. // Prime the loop with the first sample.
SensorCaptureInfo info; SensorCaptureInfo info;
SensorData data = dbReader.takeData(&info); SensorData data = dbReader.takeData(&info);
overrideStamps = data.stamp() > 1.0e9; // > year 2001 in unix-time overrideStamps = data.stamp() > UTimer::now() - 3600.0;
if(overrideStamps) if(overrideStamps)
{ {
data.setStamp(syntheticFrameIdx++ * kSyntheticFrameDt); data.setStamp(syntheticFrameIdx++ * kSyntheticFrameDt);
@@ -224,6 +231,22 @@ ReplayResult replayDatabase(
{ {
++result.framesRead; ++result.framesRead;
// Drop (stride-1) of every `stride` frames before any
// odom/rtabmap processing. Note: lastUpdateStamp /
// previousStoredOdomPose advance only on processed frames,
// so the throttle window still measures against the last
// frame we actually fed in.
if(frameStride > 1 && (result.framesRead - 1) % frameStride != 0)
{
data = dbReader.takeData(&info);
if(overrideStamps && data.isValid())
{
data.setStamp(syntheticFrameIdx++ * kSyntheticFrameDt);
}
applyGoldenGroundTruth(data);
continue;
}
// Build the motion guess from the stored odom delta if requested. // Build the motion guess from the stored odom delta if requested.
// Null Transform = no guess. // Null Transform = no guess.
Transform guess; Transform guess;