mirror of
https://github.com/introlab/rtabmap.git
synced 2026-09-03 01:50:24 +08:00
* added doc and tests for util2d.h * updated cmake-ros ci * Added util3d.h doc and tests * util3d_transforms.h: Added doc and tests * util3d_filtering.h: started doc and test * util3d_filtering.h: more tests and doc * Added more doc/tests * finished util3d_filtering doc and tests * added test for util2d::depthBleedingFiltering * Added util3d_registration tests * Added util3d_features.h doc/tests * added doc/tests for util3d_correspondences.h * added doc/gtest for util3d_mapping.h (missing hpp functions) * finished testing util3d_mapping.hpp * Added util3d_motion_estimation.h tests (2D->3D done) * finished util3d_motion_estimation.h tests * minimal util3d_surface.h * Added Transform and VisualWord tests * Added doc for CameraModel and StereoCameraModel * Added more logs in ros ci * Passing tests on fical * improved all devcontainer * added devcontainer kilted, fixed source setup.bash, removed ldconfig in ros-cmake workflow * cleanup * source ros * Added utilite tests * Added testing to appveyor, github actions cancellable on re-commit on same branch * appveyor testing without all targets * appveyor: specifying ALL_BUILD target * Fixed Util2dTest.NMSImageBoundsRespected test * Fixing PCL Indices error on old pcl * Added VWDictionary tests and doc. Fixed LSH not working (fix from https://github.com/flann-lib/flann/pull/472 * fixing some appveyor CI errors, added test to check dictionary serialization against all type * Added StereoDense, StereoBM and StereoSGBM doc and tests * Added Stereo tests * Added CameraModel and StereoCameraModel tests * Added doc and test for Statistics * Added doc/tests for Signature * Added doc/test for SensorEvent, added doc for SensorCaptureInfo * Added doc to SensorData * Added SensorData tests * Added SensorCapture and SensorCaptureThread doc and tests * fixed sensordata test * updated SSC test and doc * Added doc and tests for BayesFilter class * Enabled testing on mac, updated windows testing like on linux * added test_link * fixed unresolved on windows * fixed ThreadHandle error on macos ci * Added GPS and GeodeticCoords tests * Added tests for compression * Added Odometry tests (base class only) * Added DBDriver tests * Added coverage report * uniformized test names * fixing concurancy and coverage ci * dont built tools, examples and app for coverage build * fixed report tool rebuilt without qt compilation error * updated coverage option * updated coverage config * added doc CI job * fixing windows and mac ci errors * Added DBDriverSqlite3 tests * Added IMU tests * Added Graph tests * fixing flaky macos test * Added IMUThread and IMUFilter tests * Added Landmarks tests * Added LASWriter tests * fixing seed flaky test * fixing flaky macos timing tests * Added LocalGrid tests * Added LocalGridMaker tests * fixing ci errors * Added GlobalMap tests * Added doc for EnvSensor * Added Features2D tests * Added Registration tests * Added RegistrationVis tests * Added doc for Rtabmap and Memory classes * Added Memory and Rtabmap tests * making some tests less flaky * lcov 1.14 support * updated compatible tool arguments * Added integration tests (RGB-D, Stereo, Lidar2d, Lidar3d) * More octomap checks * Refactored how/when python interpretor is created to simplify library usage * Added python tests * fixed some flaky tests * suppressed some third party related warnings * fixed ceres tests * more flaky fixes * Fixing tests without libpointmatcher * Added RANSAC rejection filter to PCL ICP * fixing multi platform flakiness * Added test to detect regression * Fixing windows pcl link error * fixed some macos flakiness * bigger 2D2D registration error on opencv 4.6.0 * flakiness * fixing flaky tests on windows and mac * flaky thread test on slow mac VM * windows slow test * fixing more ci erros * fxing temp dir on windows * Added Optimizer tests and discovered some bugs (fixed) * fixing flaky tests in mac and windows * Added Optimizer doc * Added GTSAM BA, updated Ceres to use g2o ba parameters. Renamed g2o's ba related parameters to Optimizer group and used by both gtsam and ceres. * fixing build without gtsam * fixing home dir * fixing python ci isssues * Added multicam ba tests * Added Ceres multicam BA support * Aligned BundleAdjustment parameters with Optimizer/Strategy to avoid confusion in the code * Added BA integration test * Added robust graph optimization integration test * Added loop3it test * Added stereo20Hz test * Added smartfactor gtsam * Fixed bugged check and warn if python didn't return any descriptors * Fixing gtsam version build issues * fixing tilt on windows ci * loosing ceres integration test for ci * mac ci flakiness * updating missing param in gui * updating test bound for mac * added appearance-based tests, set min gftt quality to quality level * testing more stuff * improving features2d tests * ci flakiness * fixing flaky ci * ci fixes * flaky fixes * Added RegistrationIcp tests * Added icp integration test with real-worl corridor like env * intermediate nodes * fixing enum * Updated test to catch #1714 * Fixed 2d corridor failing on pcl * flaky pnp test * flaky brisk test * Set rtabmap_integration test as long * updating loop closure test * flaky ci tests * TEsting roundtrip g2o/toro save/load * loosing test bound * fixed cuda capable checks * flaky tests * Debugging test hanging * more debugging stuff * updating limit * windows: disabled cuda on ci to avoid incompatible driver issue. Fixing a bad test mem allocation * trying fixing cuda hanging issue * fixing ci flakyness * flaky tests * Updated BOW flaky tests by checking min precision/recall instead of recall@100precision. Fixed signature test * CameraModel::load() test initRectificationMap param * test dbdriver load dictionary idsOnly * Memory: test keepLinkedInDb param * added dummyDictionary tests * test intermediate nodes count * Added MarkerDetector tests * reverted breaking change of UMutex and USemaphore * Features2d: fixed compiltion warnings with clang about override * clang warnings * fixing test build with pcl 1.8 * g2o and gtsam build errors on android * opencv5 test fixes * disabled testing for ios and android builds * normalized endline characters for easier diff * added LF CRLF rule * bump 0.23.10. fixing doc version * Publish rtabmap website doc from ci * fixing MSCVC build error * macos icp flaky test * fixing ceres macos test bound * ficing more flaky tests * fixing opencv5 related test errors. Also fixed an actual bug in ENU_WGS84ToGeocentric_WGS84() * added comment about mrpt change * removed rosdoc2 (will add it for rtabmap_ros later) * fixing website style * updated download links * locally deployable website with api * sweep doxygen issues * improved/revised doxygen main pages * removed examples empty page * Updated doxygen style * more concise doxygen groups * added api link on main readme * fixing utilite test error * fixing CommonFilteringGroundNormalsUp test * updated precisionRecall test bounds for Freak and brief descriptors * fixing scale check in ba tests * disabled tests on windows cuda build (missing dlls amd runner cannot test cuda anyway) * ceres: missing suitesparse dep in windows ci * adjusting recall thr for fast/freak * ficing more flaky tests * fixing flaky tests * disabled coverage in ros ci * Enable integration tests for ros ci jobs * loosing up some threshold for failing tests * trigger cache * fixing test data in ros ci. Updated flaky test for mac * slaking some test limit * Fixed rtabmap-detectMoreLoopClosures inverted output value * loosing up sift recall on mac * optimizer re-ordered distribution for reproducible results (mac g2o) * macos dump test crash log * combining all tests to save time on shared library reload. Also fixed Logs with missing arguments. * Added ENABLE_FORMAT_ERRORS cmake option * do test only one time * fixed all format warnings * format security android build errors * less verbose tests * updated ImuUThread test * fixed a log * Fixed libpointmatcher 2d normals eigen issue * Fixing libpointmatcher conversion issues * fixing libpointmatcher test on windows ci * cleanup comments, relax some test thr * disabled sequoia-intel ci build (too flaky, would need extensive testing directly on that machine)
3975 lines
159 KiB
C++
3975 lines
159 KiB
C++
#include <gtest/gtest.h>
|
|
#include <rtabmap/core/Rtabmap.h>
|
|
#include <rtabmap/core/GPS.h>
|
|
#include <rtabmap/core/Landmark.h>
|
|
#include <rtabmap/core/Memory.h>
|
|
#include <rtabmap/core/VWDictionary.h>
|
|
#include <rtabmap/core/VisualWord.h>
|
|
#include <rtabmap/core/Optimizer.h>
|
|
#include <rtabmap/core/Link.h>
|
|
#include <rtabmap/core/Parameters.h>
|
|
#include <rtabmap/core/SensorData.h>
|
|
#include <rtabmap/core/Signature.h>
|
|
#include <rtabmap/core/Statistics.h>
|
|
#include <rtabmap/core/Transform.h>
|
|
#include <rtabmap/core/Graph.h>
|
|
#include <rtabmap/utilite/UFile.h>
|
|
#include <rtabmap/utilite/UConversion.h>
|
|
#include <rtabmap/utilite/ULogger.h>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <random>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include "TestUtils.h"
|
|
#include <vector>
|
|
|
|
using namespace rtabmap;
|
|
|
|
namespace {
|
|
|
|
// Rtabmap configuration that disables feature extraction (kKpMaxFeatures=-1) so tests
|
|
// focus on Rtabmap orchestration logic without exercising the feature/registration
|
|
// pipeline (which has its own dedicated tests).
|
|
ParametersMap defaultRtabmapParams(bool rgbdMode = true)
|
|
{
|
|
ParametersMap params;
|
|
params.insert(ParametersPair(Parameters::kKpMaxFeatures(), "-1"));
|
|
params.insert(ParametersPair(Parameters::kMemRehearsalSimilarity(), "1.0"));
|
|
params.insert(ParametersPair(Parameters::kMemBinDataKept(), "false"));
|
|
params.insert(ParametersPair(Parameters::kMemBadSignaturesIgnored(), "false"));
|
|
params.insert(ParametersPair(Parameters::kMemSTMSize(), "5"));
|
|
params.insert(ParametersPair(Parameters::kRGBDEnabled(), rgbdMode ? "true" : "false"));
|
|
// Disable RGB-D moveable thresholds so every frame counts as a real displacement.
|
|
params.insert(ParametersPair(Parameters::kRGBDLinearUpdate(), "0.0"));
|
|
params.insert(ParametersPair(Parameters::kRGBDAngularUpdate(), "0.0"));
|
|
return params;
|
|
}
|
|
|
|
std::string uniqueDbPath()
|
|
{
|
|
static int counter = 0;
|
|
return test::tempPath(uFormat("rtabmap_test_%d_%d.db", test::getPid(), ++counter));
|
|
}
|
|
|
|
class RtabmapFixture : public ::testing::Test
|
|
{
|
|
protected:
|
|
void SetUp() override
|
|
{
|
|
image_ = cv::Mat(8, 8, CV_8UC1, cv::Scalar(128));
|
|
covariance_ = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
rtabmap_ = new Rtabmap();
|
|
rtabmap_->init(defaultRtabmapParams());
|
|
}
|
|
|
|
void TearDown() override
|
|
{
|
|
if(rtabmap_)
|
|
{
|
|
rtabmap_->close(false);
|
|
delete rtabmap_;
|
|
rtabmap_ = nullptr;
|
|
}
|
|
}
|
|
|
|
void reinit(const ParametersMap & params)
|
|
{
|
|
if(rtabmap_)
|
|
{
|
|
rtabmap_->close(false);
|
|
delete rtabmap_;
|
|
}
|
|
rtabmap_ = new Rtabmap();
|
|
rtabmap_->init(params);
|
|
}
|
|
|
|
bool process()
|
|
{
|
|
SensorData data(image_);
|
|
data.setId(++processCount_); // any positive id
|
|
const Transform pose(float(processCount_), 0.0f, 0.0f, 0, 0, 0);
|
|
return rtabmap_->process(data, pose, covariance_);
|
|
}
|
|
|
|
bool processWith(const Transform & pose)
|
|
{
|
|
SensorData data(image_);
|
|
data.setId(++processCount_);
|
|
return rtabmap_->process(data, pose, covariance_);
|
|
}
|
|
|
|
cv::Mat image_;
|
|
cv::Mat covariance_;
|
|
Rtabmap * rtabmap_ = nullptr;
|
|
int processCount_ = 0;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constructor / init / close
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, DefaultConstructorHasEmptyState)
|
|
{
|
|
Rtabmap rtabmap;
|
|
EXPECT_EQ(rtabmap.getMemory(), nullptr);
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
EXPECT_FLOAT_EQ(rtabmap.getLoopClosureValue(), 0.0f);
|
|
EXPECT_EQ(rtabmap.getHighestHypothesisId(), 0);
|
|
EXPECT_FLOAT_EQ(rtabmap.getHighestHypothesisValue(), 0.0f);
|
|
EXPECT_EQ(rtabmap.getLastLocationId(), 0);
|
|
EXPECT_EQ(rtabmap.getLocalOptimizedPoses().size(), 0u);
|
|
EXPECT_EQ(rtabmap.getLocalConstraints().size(), 0u);
|
|
EXPECT_TRUE(rtabmap.getMapCorrection().isIdentity());
|
|
EXPECT_EQ(rtabmap.getPath().size(), 0u);
|
|
EXPECT_EQ(rtabmap.getPathStatus(), 0);
|
|
EXPECT_DOUBLE_EQ(rtabmap.getLastProcessTime(), 0.0);
|
|
}
|
|
|
|
TEST(RtabmapTest, InitInMemoryDatabase)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams());
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
EXPECT_TRUE(rtabmap.isRGBDMode());
|
|
rtabmap.close(false);
|
|
EXPECT_EQ(rtabmap.getMemory(), nullptr);
|
|
}
|
|
|
|
TEST(RtabmapTest, InitFileBackedDatabaseCreatesFile)
|
|
{
|
|
const std::string dbPath = uniqueDbPath();
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
rtabmap.close(true);
|
|
}
|
|
EXPECT_TRUE(UFile::exists(dbPath.c_str()));
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Builds a database holding a few signatures with pre-baked visual words, so a
|
|
// later init() has a real dictionary to load. Returns the word count.
|
|
int buildRtabmapDictionaryDb(const std::string & dbPath)
|
|
{
|
|
const int kKeypointsPerFrame = 3;
|
|
const int kFrames = 3;
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kKpMaxFeatures()] = "10";
|
|
params[Parameters::kKpIncrementalFlann()] = "false";
|
|
params[Parameters::kMemUseOdomFeatures()] = "true";
|
|
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
if(rtabmap.getMemory() == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
for(int frame = 0; frame < kFrames; ++frame)
|
|
{
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
SensorData data(image);
|
|
data.setId(frame + 1);
|
|
std::vector<cv::KeyPoint> kpts(kKeypointsPerFrame, cv::KeyPoint(1.f, 1.f, 1.f));
|
|
std::vector<cv::Point3f> pts3(kKeypointsPerFrame, cv::Point3f(0.f, 0.f, 1.f));
|
|
// One-hot descriptors so every keypoint becomes its own visual word.
|
|
cv::Mat descriptors = cv::Mat::zeros(kKeypointsPerFrame, kFrames*kKeypointsPerFrame, CV_32F);
|
|
for(int row = 0; row < kKeypointsPerFrame; ++row)
|
|
{
|
|
descriptors.at<float>(row, frame*kKeypointsPerFrame + row) = 1000.0f;
|
|
}
|
|
data.setFeatures(kpts, pts3, descriptors);
|
|
rtabmap.process(data, Transform(float(frame), 0.0f, 0.0f, 0, 0, 0));
|
|
}
|
|
const int wordCount = (int)rtabmap.getMemory()->getVWDictionary()->getVisualWords().size();
|
|
rtabmap.close(true);
|
|
return wordCount;
|
|
}
|
|
|
|
int countWordsWithDescriptor(const Rtabmap & rtabmap)
|
|
{
|
|
int count = 0;
|
|
const std::map<int, VisualWord *> & words =
|
|
rtabmap.getMemory()->getVWDictionary()->getVisualWords();
|
|
for(std::map<int, VisualWord *>::const_iterator iter=words.begin(); iter!=words.end(); ++iter)
|
|
{
|
|
if(!iter->second->getDescriptor().empty())
|
|
{
|
|
++count;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST(RtabmapTest, SetDummyDictionaryForwardsToMemoryOnInit)
|
|
{
|
|
// Rtabmap::setDummyDictionary() only stores the flag; it is forwarded to the
|
|
// Memory created by init(), which then loads word ids without descriptors.
|
|
const std::string dbPath = uniqueDbPath();
|
|
const int wordCount = buildRtabmapDictionaryDb(dbPath);
|
|
ASSERT_GT(wordCount, 0);
|
|
|
|
// Reference: without the flag, every word carries its descriptor.
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
EXPECT_EQ((int)rtabmap.getMemory()->getVWDictionary()->getVisualWords().size(), wordCount);
|
|
EXPECT_EQ(countWordsWithDescriptor(rtabmap), wordCount);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// With the flag set before init(): same words, none with a descriptor.
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.setDummyDictionary(true);
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
EXPECT_EQ((int)rtabmap.getMemory()->getVWDictionary()->getVisualWords().size(), wordCount);
|
|
EXPECT_EQ(countWordsWithDescriptor(rtabmap), 0);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// setDummyDictionary(false) must leave it disabled (it is the default, and the
|
|
// argument used to be ignored, which silently enabled the dummy dictionary).
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.setDummyDictionary(false);
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
EXPECT_EQ(countWordsWithDescriptor(rtabmap), wordCount);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, SetDummyDictionaryIgnoredAfterInit)
|
|
{
|
|
// Once init() created the Memory, the setter is refused (an error is logged)
|
|
// and the already loaded descriptors stay in place.
|
|
const std::string dbPath = uniqueDbPath();
|
|
const int wordCount = buildRtabmapDictionaryDb(dbPath);
|
|
ASSERT_GT(wordCount, 0);
|
|
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
ASSERT_NE(rtabmap.getMemory(), nullptr);
|
|
ASSERT_EQ(countWordsWithDescriptor(rtabmap), wordCount);
|
|
|
|
rtabmap.setDummyDictionary(true); // too late
|
|
EXPECT_EQ(countWordsWithDescriptor(rtabmap), wordCount);
|
|
|
|
rtabmap.close(false);
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, CloseWithoutInitIsSafe)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.close(false); // should not crash
|
|
EXPECT_EQ(rtabmap.getMemory(), nullptr);
|
|
}
|
|
|
|
TEST(RtabmapTest, InitRGBDModeOff)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(/*rgbdMode=*/false));
|
|
EXPECT_FALSE(rtabmap.isRGBDMode());
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// process()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, ProcessAddsSignatureToMemory)
|
|
{
|
|
EXPECT_TRUE(process());
|
|
// First generated id is kIdStart + 1 = 1.
|
|
EXPECT_EQ(rtabmap_->getLastLocationId(), Memory::kIdStart + 1);
|
|
EXPECT_EQ(rtabmap_->getSTMSize(), 1);
|
|
EXPECT_EQ(rtabmap_->getTotalMemSize(), 1);
|
|
// getLastProcessTime is wall-clock; we can't predict its value, but it must not be
|
|
// the sentinel 0.0 that the default constructor leaves behind.
|
|
EXPECT_NE(rtabmap_->getLastProcessTime(), 0.0);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ProcessSequentialIncrementsLocationId)
|
|
{
|
|
EXPECT_TRUE(process());
|
|
const int firstId = rtabmap_->getLastLocationId();
|
|
EXPECT_TRUE(process());
|
|
EXPECT_EQ(rtabmap_->getLastLocationId(), firstId + 1);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ProcessPopulatesOptimizedPosesInRGBDMode)
|
|
{
|
|
EXPECT_TRUE(process());
|
|
// In RGB-D mode the current node is added to the optimized graph.
|
|
EXPECT_EQ(rtabmap_->getLocalOptimizedPoses().size(), 1u);
|
|
EXPECT_EQ((int)rtabmap_->getLocalOptimizedPoses().count(rtabmap_->getLastLocationId()), 1);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ProcessUpdatesLastLocalizationPose)
|
|
{
|
|
const Transform pose(1.5f, 2.5f, 0.0f, 0.0f, 0.0f, 0.0f);
|
|
EXPECT_TRUE(processWith(pose));
|
|
const Transform got = rtabmap_->getLastLocalizationPose();
|
|
EXPECT_NEAR(got.x(), pose.x(), 1e-4f);
|
|
EXPECT_NEAR(got.y(), pose.y(), 1e-4f);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, NoLoopClosureOnSecondFrame)
|
|
{
|
|
process();
|
|
process();
|
|
EXPECT_EQ(rtabmap_->getLoopClosureId(), 0);
|
|
EXPECT_FLOAT_EQ(rtabmap_->getLoopClosureValue(), 0.0f);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Accessors (forward to Memory)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, MemoryStateAccessorsReflectMemory)
|
|
{
|
|
for(int i = 0; i < 3; ++i) { process(); }
|
|
EXPECT_EQ((int)rtabmap_->getSTM().size(), rtabmap_->getSTMSize());
|
|
EXPECT_EQ((int)rtabmap_->getWM().size(), rtabmap_->getWMSize());
|
|
// STM=5 in the fixture and we processed 3 frames, so all 3 are in STM and WM
|
|
// holds nothing. getTotalMemSize equals STM size in this configuration.
|
|
EXPECT_EQ(rtabmap_->getSTMSize(), 3);
|
|
EXPECT_EQ(rtabmap_->getWMSize(), 0);
|
|
EXPECT_EQ(rtabmap_->getTotalMemSize(), 3);
|
|
EXPECT_TRUE(rtabmap_->isInSTM(rtabmap_->getLastLocationId()));
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetWeightsReturnsWmEntries)
|
|
{
|
|
// Rtabmap::getWeights forwards to Memory::getWeights, which lists WM entries only.
|
|
// With STM=5, after 3 iterations every signature is still in STM -> empty map.
|
|
for(int i = 0; i < 3; ++i) { process(); }
|
|
EXPECT_EQ(rtabmap_->getWeights().size(), 0u);
|
|
|
|
// After 8 iterations total, 5 are in STM and 3 are in WM -> 3 entries.
|
|
for(int i = 0; i < 5; ++i) { process(); }
|
|
std::map<int, int> weights = rtabmap_->getWeights();
|
|
EXPECT_EQ((int)weights.size(), rtabmap_->getWMSize());
|
|
EXPECT_EQ(rtabmap_->getWMSize(), 3);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetPoseUnknownIdReturnsNull)
|
|
{
|
|
process();
|
|
const Transform pose = rtabmap_->getPose(99999);
|
|
EXPECT_TRUE(pose.isNull());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetPoseKnownIdMatchesOptimizedPose)
|
|
{
|
|
const Transform odom(3.0f, -1.0f, 0.0f, 0.0f, 0.0f, 0.0f);
|
|
processWith(odom);
|
|
const Transform pose = rtabmap_->getPose(rtabmap_->getLastLocationId());
|
|
ASSERT_FALSE(pose.isNull());
|
|
// With a single signature processed, the optimized pose equals the input odom.
|
|
EXPECT_FLOAT_EQ(pose.x(), odom.x());
|
|
EXPECT_FLOAT_EQ(pose.y(), odom.y());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Time / memory threshold setters / getters
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, SetTimeThreshold)
|
|
{
|
|
rtabmap_->setTimeThreshold(123.0f);
|
|
EXPECT_FLOAT_EQ(rtabmap_->getTimeThreshold(), 123.0f);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, SetMemoryThreshold)
|
|
{
|
|
rtabmap_->setMemoryThreshold(42);
|
|
EXPECT_EQ(rtabmap_->getMemoryThreshold(), 42);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Map correction / path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, MapCorrectionInitiallyIdentity)
|
|
{
|
|
process();
|
|
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, MapCorrectionAfterLoopClosureWithOptimizeFromGraphEndFalse)
|
|
{
|
|
// optimizeFromGraphEnd=false (default): the oldest node in the local graph is the
|
|
// anchor. After a loop closure forcing N3 to coincide with N1, N3's optimized pose
|
|
// shifts backward and mapCorrection becomes non-identity (negative x).
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "false";
|
|
// Disable the optimization-error gate so the synthetic loop closure (which is
|
|
// intentionally inconsistent with the odom chain) is not rejected.
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
reinit(params);
|
|
|
|
process(); // N1 at odom (1, 0)
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process(); // N2 at odom (2, 0)
|
|
process(); // N3 at odom (3, 0)
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
ASSERT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
|
|
// Loop closure N3 -> N1 with identity transform: N1 and N3 are at the same
|
|
// place in the world even though odom thinks they are 2 m apart. Match the
|
|
// neighbor links' information (covariance=0.01*I -> info=100*I) so the loop
|
|
// closure has the same weight as the chain edges.
|
|
Link loop(N3, N1, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
|
|
// N1 fixed at (1, 0); the least-squares optimum pulls N3 toward N1, so
|
|
// optimized(N3) < odom(N3) and mapCorrection.x() < 0. With equal-weight edges
|
|
// on a 3-node chain the analytic optimum is optimized(N3)=5/3, giving
|
|
// mapCorrection.x() = 5/3 - 3 = -4/3.
|
|
const Transform mc = rtabmap_->getMapCorrection();
|
|
EXPECT_FALSE(mc.isIdentity());
|
|
EXPECT_NEAR(mc.x(), -4.0f / 3.0f, 0.05f);
|
|
EXPECT_NEAR(mc.y(), 0.0f, 1e-3f);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, MapCorrectionAfterLoopClosureWithOptimizeFromGraphEndTrue)
|
|
{
|
|
// optimizeFromGraphEnd=true: the latest node is the anchor and stays at its
|
|
// odom pose. The optimization shifts older nodes instead, so the mapCorrection
|
|
// stays identity.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "true";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
ASSERT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
|
|
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GraphDeformsAfterLoopClosureWithOptimizeFromGraphEndFalse)
|
|
{
|
|
// More realistic scenario: 3-node chain with odom N1=(1,0)->N2=(2,0)->N3=(3,0)
|
|
// (accumulated 2 m from N1 to N3). The loop closure measures N1 only 1 m
|
|
// behind N3 (transform N3->N1 = (-1, 0, 0)), so the chain must compress to
|
|
// reconcile the two readings. Both the loop closure and the neighbor links
|
|
// share the same information weight (info = 100*I, i.e. covariance 0.01*I)
|
|
// so the optimizer balances them evenly. Analytic optimum with N1 anchored:
|
|
// N1 = (1, 0), N2 = (5/3, 0), N3 = (7/3, 0)
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "false";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N2 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
|
|
EXPECT_NEAR(rtabmap_->getPose(N1).x(), 1.0f, 0.05f);
|
|
EXPECT_NEAR(rtabmap_->getPose(N2).x(), 5.0f / 3.0f, 0.05f);
|
|
EXPECT_NEAR(rtabmap_->getPose(N3).x(), 7.0f / 3.0f, 0.05f);
|
|
// mapCorrection reflects how far the last node moved from its odom pose:
|
|
// optimized(N3) - odom(N3) = 7/3 - 3 = -2/3
|
|
EXPECT_NEAR(rtabmap_->getMapCorrection().x(), -2.0f / 3.0f, 0.05f);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GraphDeformsAfterLoopClosureWithOptimizeFromGraphEndTrue)
|
|
{
|
|
// Mirror of the test above with N3 anchored at its odom pose. The chain
|
|
// reverses: the older nodes shift forward to satisfy the same loop closure
|
|
// constraint. Analytic optimum:
|
|
// N1 = (5/3, 0), N2 = (7/3, 0), N3 = (3, 0)
|
|
// The mapCorrection stays identity even though every other node moved.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "true";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N2 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
|
|
EXPECT_NEAR(rtabmap_->getPose(N1).x(), 5.0f / 3.0f, 0.05f);
|
|
EXPECT_NEAR(rtabmap_->getPose(N2).x(), 7.0f / 3.0f, 0.05f);
|
|
EXPECT_NEAR(rtabmap_->getPose(N3).x(), 3.0f, 0.05f);
|
|
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
}
|
|
|
|
TEST(RtabmapTest, AddLinkInLocalizationModeUpdatesMapCorrectionAndLocalizationPose)
|
|
{
|
|
// Build a 2-node map (N1=(1,0), N2=(2,0)) in mapping mode, persist it, then
|
|
// reopen in localization mode (kMemIncrementalMemory=false). Process a new
|
|
// frame with odom (3, 0) -> goes into the odom cache as N3. The loop closure
|
|
// N3 -> N1 measures N1 1 m behind N3 (transform (-1, 0, 0)), so the system
|
|
// localizes N3 in the map frame at (2, 0). mapCorrection brings odom (3, 0)
|
|
// back to (2, 0) -> mapCorrection.x() = -1.
|
|
const std::string dbPath = uniqueDbPath();
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
int N1 = 0;
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
SensorData d1(image); d1.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(d1, Transform(1.0f, 0, 0, 0, 0, 0), covariance));
|
|
N1 = rtabmap.getLastLocationId();
|
|
SensorData d2(image); d2.setId(2);
|
|
ASSERT_TRUE(rtabmap.process(d2, Transform(2.0f, 0, 0, 0, 0, 0), covariance));
|
|
rtabmap.close(true);
|
|
}
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "false";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
SensorData d3(image); d3.setId(3);
|
|
ASSERT_TRUE(rtabmap.process(d3, Transform(3.0f, 0, 0, 0, 0, 0), covariance));
|
|
const int N3 = rtabmap.getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap.addLink(loop));
|
|
|
|
// Map nodes are fixed; only the new node's localization shifts.
|
|
const Transform mc = rtabmap.getMapCorrection();
|
|
EXPECT_NEAR(mc.x(), -1.0f, 0.05f);
|
|
EXPECT_NEAR(mc.y(), 0.0f, 1e-3f);
|
|
const Transform locPose = rtabmap.getLastLocalizationPose();
|
|
EXPECT_NEAR(locPose.x(), 2.0f, 0.05f);
|
|
EXPECT_NEAR(locPose.y(), 0.0f, 1e-3f);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, AddLinkInLocalizationModeRejectedWhenOptimizeFromGraphEndTrue)
|
|
{
|
|
// Documented behavior (Rtabmap.cpp): addLink in localization mode does not
|
|
// support kRGBDOptimizeFromGraphEnd=true and returns false. The state stays
|
|
// at its initial values (mapCorrection identity, lastLocalizationPose null).
|
|
const std::string dbPath = uniqueDbPath();
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
int N1 = 0;
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
SensorData d1(image); d1.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(d1, Transform(1.0f, 0, 0, 0, 0, 0), covariance));
|
|
N1 = rtabmap.getLastLocationId();
|
|
SensorData d2(image); d2.setId(2);
|
|
ASSERT_TRUE(rtabmap.process(d2, Transform(2.0f, 0, 0, 0, 0, 0), covariance));
|
|
rtabmap.close(true);
|
|
}
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "true";
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
SensorData d3(image); d3.setId(3);
|
|
ASSERT_TRUE(rtabmap.process(d3, Transform(3.0f, 0, 0, 0, 0, 0), covariance));
|
|
const int N3 = rtabmap.getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
EXPECT_FALSE(rtabmap.addLink(loop));
|
|
EXPECT_TRUE(rtabmap.getMapCorrection().isIdentity());
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, PathStatusInitiallyIdle)
|
|
{
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ClearPathSetsStatus)
|
|
{
|
|
rtabmap_->clearPath(1);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// triggerNewMap
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, TriggerNewMapInMappingModeIncrementsMapId)
|
|
{
|
|
process();
|
|
const int firstMapId = rtabmap_->getMemory()->getMapId(rtabmap_->getLastLocationId());
|
|
EXPECT_EQ(firstMapId, 0);
|
|
const int newMapId = rtabmap_->triggerNewMap();
|
|
EXPECT_EQ(newMapId, firstMapId + 1);
|
|
|
|
process();
|
|
const int secondMapId = rtabmap_->getMemory()->getMapId(rtabmap_->getLastLocationId());
|
|
EXPECT_EQ(secondMapId, newMapId);
|
|
}
|
|
|
|
TEST(RtabmapTest, TriggerNewMapInLocalizationModeReturnsMinusOne)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
EXPECT_EQ(rtabmap.triggerNewMap(), -1);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resetMemory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, ResetMemoryClearsState)
|
|
{
|
|
for(int i = 0; i < 3; ++i) { process(); }
|
|
rtabmap_->resetMemory();
|
|
EXPECT_EQ(rtabmap_->getTotalMemSize(), 0);
|
|
EXPECT_EQ(rtabmap_->getSTMSize(), 0);
|
|
EXPECT_EQ(rtabmap_->getWMSize(), 0);
|
|
EXPECT_EQ(rtabmap_->getLocalOptimizedPoses().size(), 0u);
|
|
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Labels
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, LabelLocationForwardsToMemory)
|
|
{
|
|
process();
|
|
const int id = rtabmap_->getLastLocationId();
|
|
EXPECT_TRUE(rtabmap_->labelLocation(id, "start"));
|
|
EXPECT_EQ(rtabmap_->getMemory()->getSignatureIdByLabel("start", false), id);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, LabelLocationWithUnknownIdFails)
|
|
{
|
|
EXPECT_FALSE(rtabmap_->labelLocation(99999, "ghost"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setOptimizedPoses
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, SetOptimizedPosesOverwritesInternalContainers)
|
|
{
|
|
process();
|
|
std::map<int, Transform> poses;
|
|
poses[1] = Transform(5.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
|
|
poses[2] = Transform(6.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
|
|
std::multimap<int, Link> links;
|
|
|
|
rtabmap_->setOptimizedPoses(poses, links);
|
|
EXPECT_EQ(rtabmap_->getLocalOptimizedPoses().size(), 2u);
|
|
EXPECT_EQ(rtabmap_->getLocalConstraints().size(), 0u);
|
|
EXPECT_NEAR(rtabmap_->getPose(1).x(), 5.0f, 1e-5f);
|
|
EXPECT_NEAR(rtabmap_->getPose(2).x(), 6.0f, 1e-5f);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setInitialPose
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, SetInitialPoseInMappingModeIsNoop)
|
|
{
|
|
process();
|
|
const Transform priorBefore = rtabmap_->getLastLocalizationPose();
|
|
rtabmap_->setInitialPose(Transform(99.0f, 99.0f, 0.0f, 0.0f, 0.0f, 0.0f));
|
|
// In mapping mode (default), setInitialPose only warns; last localization pose is unchanged.
|
|
const Transform after = rtabmap_->getLastLocalizationPose();
|
|
EXPECT_NEAR(after.x(), priorBefore.x(), 1e-5f);
|
|
EXPECT_NEAR(after.y(), priorBefore.y(), 1e-5f);
|
|
}
|
|
|
|
TEST(RtabmapTest, SetInitialPoseInLocalizationModeStagesPose)
|
|
{
|
|
// Build a mapping db, then reopen in localization mode and set initial pose.
|
|
const std::string dbPath = uniqueDbPath();
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
for(int i = 0; i < 6; ++i)
|
|
{
|
|
SensorData data(image);
|
|
data.setId(i + 1);
|
|
rtabmap.process(data, Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance);
|
|
}
|
|
rtabmap.close(true);
|
|
}
|
|
|
|
{
|
|
ParametersMap localParams = defaultRtabmapParams();
|
|
localParams[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(localParams, dbPath);
|
|
|
|
const Transform initial(10.0f, 5.0f, 0.0f, 0.0f, 0.0f, 0.0f);
|
|
rtabmap.setInitialPose(initial);
|
|
const Transform got = rtabmap.getLastLocalizationPose();
|
|
EXPECT_NEAR(got.x(), initial.x(), 1e-4f);
|
|
EXPECT_NEAR(got.y(), initial.y(), 1e-4f);
|
|
EXPECT_TRUE(rtabmap.getMapCorrection().isIdentity());
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// adjustLikelihood
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, AdjustLikelihoodEmptyIsNoop)
|
|
{
|
|
Rtabmap rtabmap;
|
|
std::map<int, float> likelihood;
|
|
rtabmap.adjustLikelihood(likelihood);
|
|
EXPECT_EQ(likelihood.size(), 0u);
|
|
}
|
|
|
|
TEST(RtabmapTest, AdjustLikelihoodFlattensBelowMeanPlusStdDev)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams());
|
|
|
|
// Values [1, 1, 10, 1, 1] for real-place ids 1..5.
|
|
// uMean = 2.8; uVariance uses N-1 -> variance = sum((v-mean)^2)/(N-1) = 64.8/4 = 16.2;
|
|
// stdDev = sqrt(16.2) ~= 4.024922; mean + stdDev ~= 6.824922. Only id 3 is above it.
|
|
std::map<int, float> likelihood;
|
|
likelihood[Memory::kIdVirtual] = 0.0f; // ignored in stats
|
|
for(int i = 1; i <= 5; ++i)
|
|
{
|
|
likelihood[i] = 1.0f;
|
|
}
|
|
likelihood[3] = 10.0f;
|
|
|
|
rtabmap.adjustLikelihood(likelihood);
|
|
|
|
// Values not above mean+stdDev are clamped to 1.0.
|
|
EXPECT_FLOAT_EQ(likelihood[1], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[2], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[4], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[5], 1.0f);
|
|
// Ratio=0 (Angeli): outlier scaled as (value - (stdDev - epsilon)) / mean
|
|
// = (10 - (4.024922 - 0.0001)) / 2.8 ~= 2.133992 (epsilon is a float literal
|
|
// in the implementation, so the value is computed at float precision).
|
|
EXPECT_NEAR(likelihood[3], 2.133992f, 1e-3f);
|
|
// Virtual place: mean / stdDev + 1 = 2.8 / 4.024922 + 1 ~= 1.695680.
|
|
EXPECT_NEAR(likelihood[Memory::kIdVirtual], 1.695680f, 1e-3f);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, AdjustLikelihoodUsesZscoreWhenRatioNonZero)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRtabmapVirtualPlaceLikelihoodRatio()] = "1"; // z-score formulation
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
std::map<int, float> likelihood;
|
|
likelihood[Memory::kIdVirtual] = 0.0f;
|
|
for(int i = 1; i <= 5; ++i)
|
|
{
|
|
likelihood[i] = 1.0f;
|
|
}
|
|
likelihood[3] = 10.0f;
|
|
rtabmap.adjustLikelihood(likelihood);
|
|
|
|
// Same stats as above: mean=2.8, stdDev=sqrt(16.2)~=4.024922.
|
|
EXPECT_FLOAT_EQ(likelihood[1], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[2], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[4], 1.0f);
|
|
EXPECT_FLOAT_EQ(likelihood[5], 1.0f);
|
|
// Ratio!=0 (z-score): outlier = (value - mean) / stdDev = (10 - 2.8) / 4.024922 ~= 1.788854.
|
|
EXPECT_NEAR(likelihood[3], 1.788854f, 1e-3f);
|
|
// Virtual place: stdDev / (max - mean) + 1 = 4.024922 / (10 - 2.8) + 1 ~= 1.559017.
|
|
EXPECT_NEAR(likelihood[Memory::kIdVirtual], 1.559017f, 1e-3f);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, AdjustLikelihoodFallsBackToTwoWhenAllValuesEqual)
|
|
{
|
|
// All real-place values equal -> mean=value, stdDev=0. For both approaches:
|
|
// - no value > mean+stdDev, so the outlier branch is skipped (everything stays
|
|
// at 1.0 after the unconditional `iter->second = 1.0f` line),
|
|
// - the virtual-place gate fails for both approaches (Angeli needs stdDev>eps,
|
|
// z-score needs max>mean) -> fallback "else { virtual = 2.0 }".
|
|
for(const std::string & ratio : {std::string("0"), std::string("1")})
|
|
{
|
|
SCOPED_TRACE("ratio=" + ratio);
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRtabmapVirtualPlaceLikelihoodRatio()] = ratio;
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
std::map<int, float> likelihood;
|
|
likelihood[Memory::kIdVirtual] = 0.0f;
|
|
for(int i = 1; i <= 5; ++i) likelihood[i] = 1.0f;
|
|
|
|
rtabmap.adjustLikelihood(likelihood);
|
|
|
|
for(int i = 1; i <= 5; ++i)
|
|
{
|
|
EXPECT_FLOAT_EQ(likelihood[i], 1.0f) << "id=" << i;
|
|
}
|
|
EXPECT_FLOAT_EQ(likelihood[Memory::kIdVirtual], 2.0f);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
}
|
|
|
|
TEST(RtabmapTest, AdjustLikelihoodWithoutOutliersStillSetsVirtualPlace)
|
|
{
|
|
// Inputs picked so no value crosses the outlier gate but the spread is still
|
|
// non-degenerate:
|
|
// values = {1.0, 1.0, 1.1, 1.1, 1.1}
|
|
// mean = 1.06
|
|
// variance (N-1) = sum((v-mean)^2)/4 = (2*0.06^2 + 3*0.04^2)/4 = 0.012/4 = 0.003
|
|
// stdDev = sqrt(0.003) ~= 0.054772
|
|
// mean+stdDev ~= 1.114772 > max=1.1 -> no outliers
|
|
// Both approaches skip the outlier branch but still compute the virtual place
|
|
// from their respective formulas (no fallback hit here).
|
|
const float mean = 1.06f;
|
|
const float stdDev = std::sqrt(0.003f);
|
|
const float maxVal = 1.1f;
|
|
|
|
for(const std::string & ratio : {std::string("0"), std::string("1")})
|
|
{
|
|
SCOPED_TRACE("ratio=" + ratio);
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRtabmapVirtualPlaceLikelihoodRatio()] = ratio;
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
std::map<int, float> likelihood;
|
|
likelihood[Memory::kIdVirtual] = 0.0f;
|
|
likelihood[1] = 1.0f;
|
|
likelihood[2] = 1.0f;
|
|
likelihood[3] = 1.1f;
|
|
likelihood[4] = 1.1f;
|
|
likelihood[5] = 1.1f;
|
|
|
|
rtabmap.adjustLikelihood(likelihood);
|
|
|
|
// Outlier branch skipped -> all real-place values clamped to 1.0.
|
|
for(int i = 1; i <= 5; ++i)
|
|
{
|
|
EXPECT_FLOAT_EQ(likelihood[i], 1.0f) << "id=" << i;
|
|
}
|
|
// Virtual place set by the active formula (no 2.0 fallback).
|
|
const float expected = (ratio == "0")
|
|
? mean / stdDev + 1.0f // Angeli
|
|
: stdDev / (maxVal - mean) + 1.0f; // z-score
|
|
EXPECT_NEAR(likelihood[Memory::kIdVirtual], expected, 1e-2f);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseParameters / getParameters
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, ParseParametersUpdatesSettings)
|
|
{
|
|
ParametersMap params;
|
|
params.insert(ParametersPair(Parameters::kRtabmapTimeThr(), "555.0"));
|
|
params.insert(ParametersPair(Parameters::kRtabmapMemoryThr(), "33"));
|
|
rtabmap_->parseParameters(params);
|
|
|
|
EXPECT_FLOAT_EQ(rtabmap_->getTimeThreshold(), 555.0f);
|
|
EXPECT_EQ(rtabmap_->getMemoryThreshold(), 33);
|
|
|
|
const ParametersMap & got = rtabmap_->getParameters();
|
|
ASSERT_TRUE(got.count(Parameters::kRtabmapTimeThr()) > 0);
|
|
EXPECT_EQ(got.at(Parameters::kRtabmapTimeThr()), std::string("555.0"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Working directory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, SetWorkingDirectoryReflectsInGetter)
|
|
{
|
|
// setWorkingDirectory() silently no-ops if the path doesn't exist on disk,
|
|
// so use the platform's temp dir (which exists) instead of a hardcoded
|
|
// POSIX-only "/tmp". tempPath("") returns "<tempdir>/"; strip the trailing
|
|
// separator so the assertion compares the dir itself.
|
|
std::string wd = test::tempPath("");
|
|
if(!wd.empty() && (wd.back() == '/' || wd.back() == '\\'))
|
|
{
|
|
wd.pop_back();
|
|
}
|
|
rtabmap_->setWorkingDirectory(wd);
|
|
EXPECT_EQ(rtabmap_->getWorkingDir(), wd);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getStatistics
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GetStatisticsRefreshesOnProcess)
|
|
{
|
|
process();
|
|
const Statistics & stats = rtabmap_->getStatistics();
|
|
EXPECT_EQ(stats.refImageId(), rtabmap_->getLastLocationId());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// addNodesToRepublish
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace {
|
|
// Helper for the addNodesToRepublish tests: builds a 6-node chain in mapping
|
|
// mode, persists it to @p dbPath and returns the resulting node ids in order.
|
|
// The map is the same for every test so the republish lookup hits a known graph.
|
|
std::vector<int> buildSixNodeMap(const std::string & dbPath)
|
|
{
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
std::vector<int> ids;
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
for(int i = 0; i < 6; ++i)
|
|
{
|
|
SensorData data(image);
|
|
data.setId(i + 1);
|
|
EXPECT_TRUE(rtabmap.process(data, Transform(float(i), 0, 0, 0, 0, 0), covariance));
|
|
ids.push_back(rtabmap.getLastLocationId());
|
|
}
|
|
rtabmap.close(true);
|
|
return ids;
|
|
}
|
|
} // namespace
|
|
|
|
TEST(RtabmapTest, AddNodesToRepublishEmitsRequestedNodesInStatistics)
|
|
{
|
|
// Build a 6-node chain in mapping mode then reopen in localization mode and
|
|
// drop the robot at (3, 0) (the pose of the 4th map node) via setInitialPose.
|
|
// addNodesToRepublish({ids[0], ids[1]}) -- 2 requests, within the default
|
|
// kRtabmapMaxRepublished=2. After process(), both signatures must appear in
|
|
// the Statistics' signature data alongside the just-processed last signature.
|
|
const std::string dbPath = uniqueDbPath();
|
|
const std::vector<int> ids = buildSixNodeMap(dbPath);
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
rtabmap.setInitialPose(Transform(3.0f, 0, 0, 0, 0, 0));
|
|
rtabmap.addNodesToRepublish({ids[0], ids[1]});
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
SensorData data(image); data.setId(100);
|
|
rtabmap.process(data, Transform(3.0f, 0, 0, 0, 0, 0), covariance);
|
|
|
|
const Statistics & stats = rtabmap.getStatistics();
|
|
EXPECT_EQ(stats.getSignaturesData().count(ids[0]), 1u);
|
|
EXPECT_EQ(stats.getSignaturesData().count(ids[1]), 1u);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, AddNodesToRepublishCapsPerIterationAtMaxRepublished)
|
|
{
|
|
// Same setup but request 5 ids. The cap kRtabmapMaxRepublished=2 means only
|
|
// 2 of the 5 requested signatures land in iteration 1's Statistics. Each
|
|
// emitted signature is removed from the queue (Rtabmap.cpp _nodesToRepublish.erase),
|
|
// so iteration 2 must emit a different 2 ids from the queue's remainder.
|
|
const std::string dbPath = uniqueDbPath();
|
|
const std::vector<int> ids = buildSixNodeMap(dbPath);
|
|
const std::vector<int> requested = {ids[0], ids[1], ids[2], ids[3], ids[4]};
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
rtabmap.setInitialPose(Transform(3.0f, 0, 0, 0, 0, 0));
|
|
rtabmap.addNodesToRepublish(requested);
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
auto requestedInStats = [&]() {
|
|
std::set<int> hits;
|
|
const auto & sigs = rtabmap.getStatistics().getSignaturesData();
|
|
for(int id : requested)
|
|
{
|
|
if(sigs.count(id) > 0) hits.insert(id);
|
|
}
|
|
return hits;
|
|
};
|
|
|
|
// Iteration 1: cap kicks in -> exactly 2 from the request.
|
|
SensorData data1(image); data1.setId(100);
|
|
rtabmap.process(data1, Transform(3.0f, 0, 0, 0, 0, 0), covariance);
|
|
const std::set<int> emitted1 = requestedInStats();
|
|
EXPECT_EQ(emitted1.size(), 2u);
|
|
|
|
// Iteration 2: another 2 from the queue's remainder, no overlap with iter 1.
|
|
SensorData data2(image); data2.setId(101);
|
|
rtabmap.process(data2, Transform(3.0f, 0, 0, 0, 0, 0), covariance);
|
|
const std::set<int> emitted2 = requestedInStats();
|
|
EXPECT_EQ(emitted2.size(), 2u);
|
|
std::set<int> intersection;
|
|
std::set_intersection(emitted1.begin(), emitted1.end(),
|
|
emitted2.begin(), emitted2.end(),
|
|
std::inserter(intersection, intersection.begin()));
|
|
EXPECT_EQ(intersection.size(), 0u);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, AddNodesToRepublishWithEmptyClearsQueueForNextIteration)
|
|
{
|
|
// Request 5 ids, then clear the queue with {}. On the next process() none of
|
|
// the originally-requested signatures appear in Statistics.
|
|
const std::string dbPath = uniqueDbPath();
|
|
const std::vector<int> ids = buildSixNodeMap(dbPath);
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
rtabmap.setInitialPose(Transform(3.0f, 0, 0, 0, 0, 0));
|
|
rtabmap.addNodesToRepublish({ids[0], ids[1], ids[2], ids[3], ids[4]});
|
|
rtabmap.addNodesToRepublish({}); // clears
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
SensorData data(image); data.setId(100);
|
|
rtabmap.process(data, Transform(3.0f, 0, 0, 0, 0, 0), covariance);
|
|
|
|
const Statistics & stats = rtabmap.getStatistics();
|
|
for(int id : {ids[0], ids[1], ids[2], ids[3], ids[4]})
|
|
{
|
|
EXPECT_EQ(stats.getSignaturesData().count(id), 0u) << "id=" << id;
|
|
}
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getInformation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GetInformationInvertsCovariance)
|
|
{
|
|
cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.5;
|
|
cv::Mat info = rtabmap_->getInformation(cov);
|
|
ASSERT_EQ(info.rows, 6);
|
|
ASSERT_EQ(info.cols, 6);
|
|
// inv(diag(0.5)) = diag(2)
|
|
for(int i = 0; i < 6; ++i)
|
|
{
|
|
EXPECT_NEAR(info.at<double>(i, i), 2.0, 1e-6);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// rejectLastLoopClosure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, RejectLastLoopClosureRemovesLinkAndResetsHypothesis)
|
|
{
|
|
// Build a 3-node chain and inject a user loop closure (N3->N1). After
|
|
// addLink(), the loop link exists in Memory and _constraints includes it,
|
|
// and mapCorrection has shifted because the graph re-optimized.
|
|
// rejectLastLoopClosure() must: (1) remove the loop link from Memory and
|
|
// _constraints, (2) clear _loopClosureHypothesis, (3) re-optimize without it
|
|
// 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();
|
|
process();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kUserClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
ASSERT_FALSE(rtabmap_->getMapCorrection().isIdentity());
|
|
|
|
rtabmap_->rejectLastLoopClosure();
|
|
|
|
EXPECT_EQ(rtabmap_->getLoopClosureId(), 0);
|
|
// Loop link gone from memory.
|
|
const std::multimap<int, Link> & links = rtabmap_->getMemory()->getSignature(N3)->getLinks();
|
|
for(const auto & kv : links)
|
|
{
|
|
EXPECT_NE(kv.second.type(), Link::kUserClosure);
|
|
}
|
|
// 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)
|
|
{
|
|
// Without any loop-closure link, the rollback path is skipped: hypothesis
|
|
// stays at 0, mapCorrection stays identity, and no link is removed.
|
|
process();
|
|
process();
|
|
rtabmap_->rejectLastLoopClosure();
|
|
EXPECT_EQ(rtabmap_->getLoopClosureId(), 0);
|
|
EXPECT_TRUE(rtabmap_->getMapCorrection().isIdentity());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deleteLastLocation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, DeleteLastLocationRemovesFromStmAndOptimizedPoses)
|
|
{
|
|
// 3 signatures processed -> all 3 in STM (STM=5 default), all 3 in optimized
|
|
// poses (RGBD mode). deleteLastLocation removes the last one from both.
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N2 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
ASSERT_TRUE(rtabmap_->isInSTM(N3));
|
|
ASSERT_NE(rtabmap_->getPose(N3).isNull(), true);
|
|
|
|
rtabmap_->deleteLastLocation();
|
|
|
|
EXPECT_FALSE(rtabmap_->isInSTM(N3));
|
|
EXPECT_TRUE(rtabmap_->isInSTM(N2));
|
|
EXPECT_TRUE(rtabmap_->isInSTM(N1));
|
|
EXPECT_TRUE(rtabmap_->getPose(N3).isNull());
|
|
// Remaining nodes still reachable.
|
|
EXPECT_FALSE(rtabmap_->getPose(N2).isNull());
|
|
|
|
// _idCount is not rewound on delete: the next signature gets a fresh id
|
|
// strictly greater than the removed one.
|
|
process();
|
|
const int Nnext = rtabmap_->getLastLocationId();
|
|
EXPECT_GT(Nnext, N3);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, DeleteLastLocationIsNoOpWhenStmEmpty)
|
|
{
|
|
// Fresh memory with no signatures -> deleteLastLocation should not crash.
|
|
rtabmap_->deleteLastLocation();
|
|
EXPECT_EQ(rtabmap_->getSTMSize(), 0);
|
|
}
|
|
|
|
// Regression: Rtabmap/PublishLastSignature=false instructs process() to strip
|
|
// the bulky raw/compressed payloads (image, scan, user data) from the
|
|
// published Statistics signature, but it must NOT drop the occupancy grid --
|
|
// downstream consumers (e.g. ROS occupancy-grid publishers) rely on it even
|
|
// when binary image data isn't republished. Compounded with Mem/BinDataKept
|
|
// =false the bug previously cleared the grid in the published copy (the
|
|
// default-arg clearCompressedData() / clearRawData() both wiped all four
|
|
// payload kinds). Fixed by passing occupancyGrid=false to both calls.
|
|
TEST(RtabmapTest, ProcessKeepsOccupancyGridWhenPublishLastSignatureDataDisabled)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRtabmapPublishLastSignature()] = "false";
|
|
params[Parameters::kMemBinDataKept()] = "false";
|
|
// Memory only stores the user-attached grid if local occupancy grid
|
|
// creation is enabled (otherwise the grid set on the input SensorData is
|
|
// dropped during Memory::createSignature).
|
|
params[Parameters::kRGBDCreateOccupancyGrid()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
SensorData data(image);
|
|
data.setId(1);
|
|
// Attach a small synthetic occupancy grid -- setOccupancyGrid compresses
|
|
// internally, so the compressed cells are populated immediately.
|
|
const cv::Mat ground = cv::Mat::ones(1, 16, CV_32FC2);
|
|
const cv::Mat obstacles = cv::Mat::ones(1, 16, CV_32FC2) * 2.0f;
|
|
const cv::Mat empty = cv::Mat::ones(1, 16, CV_32FC2) * 3.0f;
|
|
const float cellSize = 0.05f;
|
|
const cv::Point3f viewPoint(0.0f, 0.0f, 0.0f);
|
|
data.setOccupancyGrid(ground, obstacles, empty, cellSize, viewPoint);
|
|
ASSERT_FALSE(data.gridGroundCellsCompressed().empty());
|
|
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
ASSERT_TRUE(rtabmap.process(data, Transform(0, 0, 0, 0, 0, 0), cov));
|
|
|
|
const Signature & published = rtabmap.getStatistics().getLastSignatureData();
|
|
ASSERT_GT(published.id(), 0) << "Statistics should still hold a signature even when PublishLastSignature=false";
|
|
|
|
// PublishLastSignature=false strips image/scan/user data (raw and
|
|
// compressed)...
|
|
EXPECT_TRUE(published.sensorData().imageCompressed().empty());
|
|
EXPECT_TRUE(published.sensorData().laserScanCompressed().isEmpty());
|
|
EXPECT_TRUE(published.sensorData().userDataCompressed().empty());
|
|
EXPECT_TRUE(published.sensorData().imageRaw().empty());
|
|
EXPECT_TRUE(published.sensorData().laserScanRaw().isEmpty());
|
|
EXPECT_TRUE(published.sensorData().userDataRaw().empty());
|
|
|
|
// ...but the occupancy grid (compressed AND raw) must survive.
|
|
EXPECT_FALSE(published.sensorData().gridGroundCellsCompressed().empty())
|
|
<< "compressed ground grid was cleared (regression)";
|
|
EXPECT_FALSE(published.sensorData().gridObstacleCellsCompressed().empty())
|
|
<< "compressed obstacle grid was cleared (regression)";
|
|
EXPECT_FALSE(published.sensorData().gridEmptyCellsCompressed().empty())
|
|
<< "compressed empty grid was cleared (regression)";
|
|
EXPECT_FALSE(published.sensorData().gridGroundCellsRaw().empty())
|
|
<< "raw ground grid was cleared (regression)";
|
|
EXPECT_FALSE(published.sensorData().gridObstacleCellsRaw().empty())
|
|
<< "raw obstacle grid was cleared (regression)";
|
|
EXPECT_FALSE(published.sensorData().gridEmptyCellsRaw().empty())
|
|
<< "raw empty grid was cleared (regression)";
|
|
EXPECT_FLOAT_EQ(published.sensorData().gridCellSize(), cellSize);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getSignatureCopy
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, GetSignatureCopyReturnsRequestedPayloads)
|
|
{
|
|
// kMemBinDataKept=true so compressed image data survives on the signature.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
SensorData data(image); data.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(data, Transform(1.0f, 0, 0, 0, 0, 0), covariance));
|
|
const int id = rtabmap.getLastLocationId();
|
|
|
|
// Request only image: scan/userData/grid/words/globalDescriptors omitted.
|
|
const Signature s = rtabmap.getSignatureCopy(id, /*images=*/true,
|
|
/*scan=*/false, /*userData=*/false, /*occupancyGrid=*/false,
|
|
/*withWords=*/false, /*withGlobalDescriptors=*/false);
|
|
EXPECT_EQ(s.id(), id);
|
|
EXPECT_EQ(s.sensorData().imageCompressed().rows, 1); // populated
|
|
EXPECT_EQ(s.sensorData().laserScanCompressed().isEmpty(), true);
|
|
EXPECT_EQ(s.sensorData().userDataCompressed().rows, 0);
|
|
EXPECT_FLOAT_EQ(s.sensorData().gridCellSize(), 0.0f);
|
|
EXPECT_EQ(s.getWords().size(), 0u);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, GetSignatureCopyOmitsImageWhenNotRequested)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
SensorData data(image); data.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(data, Transform(0, 0, 0, 0, 0, 0), covariance));
|
|
const int id = rtabmap.getLastLocationId();
|
|
|
|
const Signature s = rtabmap.getSignatureCopy(id, /*images=*/false,
|
|
/*scan=*/false, /*userData=*/false, /*occupancyGrid=*/false,
|
|
/*withWords=*/false, /*withGlobalDescriptors=*/false);
|
|
EXPECT_EQ(s.id(), id);
|
|
EXPECT_EQ(s.sensorData().imageCompressed().rows, 0);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getGraph
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GetGraphReturnsLocalOptimizedPosesAndConstraints)
|
|
{
|
|
// 3-node chain: poses are the odometry positions (1,0), (2,0), (3,0).
|
|
// getGraph(optimized=true, global=false) returns local optimized poses.
|
|
process();
|
|
process();
|
|
process();
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> constraints;
|
|
rtabmap_->getGraph(poses, constraints, /*optimized=*/true, /*global=*/false);
|
|
|
|
EXPECT_EQ(poses.size(), 3u);
|
|
// 2 neighbor links in a 3-node chain.
|
|
EXPECT_EQ(constraints.size(), 2u);
|
|
for(const auto & kv : constraints)
|
|
{
|
|
EXPECT_EQ(kv.second.type(), Link::kNeighbor);
|
|
}
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetGraphReturnsSignaturesWhenRequested)
|
|
{
|
|
process();
|
|
process();
|
|
process();
|
|
std::map<int, Signature> signatures;
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> constraints;
|
|
rtabmap_->getGraph(poses, constraints, /*optimized=*/true, /*global=*/false,
|
|
&signatures, /*withImages=*/false, /*withScan=*/false,
|
|
/*withUserData=*/false, /*withGrid=*/false);
|
|
|
|
EXPECT_EQ(signatures.size(), 3u);
|
|
for(const auto & kv : signatures)
|
|
{
|
|
EXPECT_EQ(kv.second.id(), kv.first);
|
|
}
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetGraphOptimizedFalseReturnsOdomPosesUnshiftedByLoopClosure)
|
|
{
|
|
// Build a 3-node chain and inject a loop closure that pulls N3 toward N1.
|
|
// With optimized=true the returned poses reflect the optimizer output (N3
|
|
// near 7/3). With optimized=false the poses come from Memory's odom poses
|
|
// instead -- N3 stays at its raw odom pose (3, 0).
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
reinit(params);
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
|
|
Link loop(N3, N1, Link::kGlobalClosure,
|
|
Transform(-1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap_->addLink(loop));
|
|
|
|
// optimized=true -> N3 is pulled to ~7/3.
|
|
{
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> constraints;
|
|
rtabmap_->getGraph(poses, constraints, /*optimized=*/true, /*global=*/false);
|
|
EXPECT_NEAR(poses.at(N3).x(), 7.0f / 3.0f, 0.05f);
|
|
}
|
|
// optimized=false -> N3 stays at odom (3, 0).
|
|
{
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> constraints;
|
|
rtabmap_->getGraph(poses, constraints, /*optimized=*/false, /*global=*/false);
|
|
EXPECT_NEAR(poses.at(N3).x(), 3.0f, 1e-3f);
|
|
// The loop link is still part of the graph constraints regardless of
|
|
// whether we asked for optimized or odom poses.
|
|
ASSERT_EQ(constraints.size(), 3u);
|
|
int neighbors = 0, loops = 0;
|
|
for(const auto & kv : constraints)
|
|
{
|
|
if(kv.second.type() == Link::kNeighbor) ++neighbors;
|
|
else if(kv.second.type() == Link::kGlobalClosure) ++loops;
|
|
}
|
|
EXPECT_EQ(neighbors, 2);
|
|
EXPECT_EQ(loops, 1);
|
|
}
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetGraphGlobalTrueIncludesLtmNodesWhereLocalDoesNot)
|
|
{
|
|
// Force memory management by capping WM at 2 signatures via kRtabmapMemoryThr.
|
|
// With STM=1 and 6 processed frames, the oldest nodes get transferred to LTM
|
|
// at every iteration once the cap is hit. global=false then returns only the
|
|
// signatures still in WM/STM, while global=true walks the database links and
|
|
// returns every node.
|
|
ParametersMap params = defaultRtabmapParams(/*rgbdMode=*/true);
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "2";
|
|
reinit(params);
|
|
|
|
std::vector<int> ids;
|
|
for(int i = 0; i < 6; ++i)
|
|
{
|
|
ASSERT_TRUE(process());
|
|
ids.push_back(rtabmap_->getLastLocationId());
|
|
}
|
|
|
|
// Some of the oldest nodes have been transferred to LTM.
|
|
int inLtm = 0;
|
|
for(int id : ids)
|
|
{
|
|
if(rtabmap_->getMemory()->isInLTM(id)) ++inLtm;
|
|
}
|
|
ASSERT_GT(inLtm, 0);
|
|
|
|
std::map<int, Transform> posesLocal;
|
|
std::multimap<int, Link> linksLocal;
|
|
rtabmap_->getGraph(posesLocal, linksLocal, /*optimized=*/false, /*global=*/false);
|
|
|
|
std::map<int, Transform> posesGlobal;
|
|
std::multimap<int, Link> linksGlobal;
|
|
rtabmap_->getGraph(posesGlobal, linksGlobal, /*optimized=*/false, /*global=*/true);
|
|
|
|
// global=true walks into the database, so it surfaces strictly more nodes.
|
|
EXPECT_GT(posesGlobal.size(), posesLocal.size());
|
|
EXPECT_EQ(posesGlobal.size(), ids.size());
|
|
// LTM nodes appear in the global graph but not the local one.
|
|
for(int id : ids)
|
|
{
|
|
if(rtabmap_->getMemory()->isInLTM(id))
|
|
{
|
|
EXPECT_EQ(posesLocal.count(id), 0u) << "local should not have LTM id=" << id;
|
|
EXPECT_EQ(posesGlobal.count(id), 1u) << "global should have LTM id=" << id;
|
|
}
|
|
}
|
|
// Same containment for the links between nodes.
|
|
EXPECT_GT(linksGlobal.size(), linksLocal.size());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// detectMoreLoopClosures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, DetectMoreLoopClosuresFailsInAppearanceOnlyMode)
|
|
{
|
|
// kRGBDEnabled=false -> not RGBD-SLAM mode -> returns -1.
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(/*rgbdMode=*/false));
|
|
EXPECT_EQ(rtabmap.detectMoreLoopClosures(1.0f, 0.0f, 1, true, true), -1);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, DetectMoreLoopClosuresRejectsZeroSessionFlags)
|
|
{
|
|
// Both intra and inter session must not be false simultaneously.
|
|
EXPECT_EQ(rtabmap_->detectMoreLoopClosures(1.0f, 0.0f, 1, false, false), -1);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, DetectMoreLoopClosuresReturnsZeroWithoutLoopCandidates)
|
|
{
|
|
// Without features (kKpMaxFeatures=-1), the registration pipeline can't
|
|
// detect any loop closures. The function still completes (returns 0).
|
|
process();
|
|
process();
|
|
process();
|
|
EXPECT_EQ(rtabmap_->detectMoreLoopClosures(0.5f, M_PI / 4.0f, 1, true, true), 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// refineLinks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, RefineLinksFailsInAppearanceOnlyMode)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(/*rgbdMode=*/false));
|
|
EXPECT_EQ(rtabmap.refineLinks(), -1);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, RefineLinksReturnsZeroWhenComputeTransformFails)
|
|
{
|
|
// With kKpMaxFeatures=-1 the registration pipeline has no features to match,
|
|
// so every computeTransform() returns a null transform and nothing is refined.
|
|
process();
|
|
process();
|
|
process();
|
|
EXPECT_EQ(rtabmap_->refineLinks(), 0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computePath
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, ComputePathFailsInAppearanceOnlyMode)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(/*rgbdMode=*/false));
|
|
EXPECT_FALSE(rtabmap.computePath(1, false));
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ComputePathToKnownNodeSucceedsAndPopulatesPath)
|
|
{
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
process();
|
|
const int N4 = rtabmap_->getLastLocationId();
|
|
|
|
EXPECT_TRUE(rtabmap_->computePath(N1, /*global=*/false));
|
|
// Path starts at the current node (N4) and ends at the target N1.
|
|
const std::vector<std::pair<int, Transform> > & path = rtabmap_->getPath();
|
|
ASSERT_EQ(path.size(), 4u);
|
|
EXPECT_EQ(path.front().first, N4);
|
|
EXPECT_EQ(path.back().first, N1);
|
|
// All nodes are within kRGBDLocalRadius (default 10 m), so updateGoalIndex
|
|
// advances _pathGoalIndex to the final target.
|
|
EXPECT_EQ(rtabmap_->getPathCurrentGoalId(), N1);
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, FollowPathAdvancesCurrentIndexAndReachesGoal)
|
|
{
|
|
// Build 4-node chain N1..N4 at odom poses (1,0), (2,0), (3,0), (4,0). Then
|
|
// plan a path from N4 back to N1. Simulate the robot following the path by
|
|
// processing 3 more frames at intermediate poses; each frame calls
|
|
// updateGoalIndex() which (a) advances _pathCurrentIndex to the nearest path
|
|
// node, (b) advances _pathGoalIndex up to kRGBDLocalRadius along the path, and
|
|
// (c) clears the path with status=1 when within kRGBDGoalReachedRadius of the
|
|
// final target. The default kRGBDLocalRadius is 10 m, larger than the whole
|
|
// path, so the goal index would jump straight to the end. Reduce it to 0.5 m
|
|
// so the current goal advances step by step.
|
|
//
|
|
// updateGoalIndex computes the new goal *before* the new current index, so
|
|
// within a single process() call goal advances at most one step from the
|
|
// old current index; the next process() call can then advance one more.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDLocalRadius()] = "0.5";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
const int N3 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N4 = rtabmap_->getLastLocationId();
|
|
ASSERT_TRUE(rtabmap_->computePath(N1, /*global=*/false));
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 4u);
|
|
ASSERT_EQ(rtabmap_->getPath().front().first, N4);
|
|
ASSERT_EQ(rtabmap_->getPath().back().first, N1);
|
|
// Right after planning, _pathCurrentIndex is at the start (N4) and the
|
|
// current goal is one step ahead (N3, distance 1 m > localRadius).
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 0u);
|
|
EXPECT_EQ(rtabmap_->getPathCurrentGoalId(), N3);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0); // active
|
|
|
|
// Step 1: robot at (3, 0), on top of N3. _pathCurrentIndex catches up to the
|
|
// previous goal (N3). The goal-update loop used the old current index, so the
|
|
// goal still points to N3 -- it only advances on the *next* iteration.
|
|
ASSERT_TRUE(processWith(Transform(3.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 1u);
|
|
EXPECT_EQ(rtabmap_->getPathCurrentGoalId(), N3);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
// Step 2: robot at (2, 0). _pathCurrentIndex advances to 2, and the goal
|
|
// advances by one step to the node after the new current -- here that's the
|
|
// node the robot just landed on (N2).
|
|
ASSERT_TRUE(processWith(Transform(2.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 2u);
|
|
EXPECT_EQ(rtabmap_->getPathCurrentGoalId(), rtabmap_->getPath()[2].first);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
// Step 3: robot at (1, 0) (on N1). Distance to final goal == 0, well within
|
|
// kRGBDGoalReachedRadius (0.5 m default). clearPath(1) fires before the
|
|
// goal-update loop, so status reports "reached" and the path is cleared.
|
|
ASSERT_TRUE(processWith(Transform(1.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, FollowPathWithLtmNodesRetrievesThemAndReachesGoal)
|
|
{
|
|
// Same path-following scenario as the test above, but the two oldest nodes
|
|
// (N1 and N2) are transferred to LTM via memory management before planning.
|
|
// kRGBDMaxLocalRetrieved=2 default lets process() pull both back into WM on
|
|
// each tick to keep the path-tracking logic working.
|
|
// Build phase: aggressive WM cap + disabled retrieval so memory management
|
|
// fully evicts the oldest nodes to LTM without auto-retrieving them back.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
const int N2 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
const int N4 = rtabmap_->getLastLocationId();
|
|
ASSERT_TRUE(rtabmap_->getMemory()->isInLTM(N1));
|
|
ASSERT_TRUE(rtabmap_->getMemory()->isInLTM(N2));
|
|
|
|
// Path-follow phase: re-enable retrieval (default 2) and lift the WM cap so
|
|
// retrieved path nodes don't immediately get pushed back to LTM during the
|
|
// next iteration.
|
|
ParametersMap updated;
|
|
updated[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
updated[Parameters::kRtabmapMemoryThr()] = "0";
|
|
rtabmap_->parseParameters(updated);
|
|
|
|
// Plan a global path so graph::computePath can walk through LTM nodes via
|
|
// database lookups.
|
|
ASSERT_TRUE(rtabmap_->computePath(N1, /*global=*/true));
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 4u);
|
|
EXPECT_EQ(rtabmap_->getPath().front().first, N4);
|
|
EXPECT_EQ(rtabmap_->getPath().back().first, N1);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
// Step 1: at (3, 0). process() walks the path forward, sees N2 and N1 are
|
|
// not in WM, and queues them for retrieval (up to kRGBDMaxLocalRetrieved).
|
|
// After the call both LTM nodes are back in WM.
|
|
ASSERT_TRUE(processWith(Transform(3.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_TRUE(rtabmap_->getMemory()->isInWM(N1));
|
|
EXPECT_TRUE(rtabmap_->getMemory()->isInWM(N2));
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
// Step 2: at (2, 0). Still tracking the path.
|
|
ASSERT_TRUE(processWith(Transform(2.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
// Step 3: at (1, 0). Goal reached, path cleared.
|
|
ASSERT_TRUE(processWith(Transform(1.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, FollowPathSurvivesPathTailInLtm)
|
|
{
|
|
// Regression test for a null-pointer dereference in Rtabmap::updateGoalIndex.
|
|
// When kRtabmapMemoryThr keeps memory pressure on during path-follow, nodes
|
|
// in the path tail (path[0..currentIndex-1]) can be transferred to LTM. The
|
|
// virtual-link cleanup loop in updateGoalIndex must skip those tail nodes
|
|
// gracefully -- previously it null-deref'd `s->getWeight()` after a
|
|
// successful `if(s)` check, crashing on step 3 of this scenario.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
reinit(params);
|
|
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
process();
|
|
ASSERT_TRUE(rtabmap_->getMemory()->isInLTM(N1));
|
|
|
|
ParametersMap updated;
|
|
updated[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
// Note: kRtabmapMemoryThr stays at 1 so the path tail keeps getting evicted.
|
|
rtabmap_->parseParameters(updated);
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(N1, /*global=*/true));
|
|
|
|
// Three ticks. Step 3 is where path[0]=N4 and path[1]=N3 are both in LTM
|
|
// when updateGoalIndex runs -- the previous null-deref bug crashed here.
|
|
ASSERT_TRUE(processWith(Transform(3.0f, 0, 0, 0, 0, 0)));
|
|
ASSERT_TRUE(processWith(Transform(2.0f, 0, 0, 0, 0, 0)));
|
|
ASSERT_TRUE(processWith(Transform(1.0f, 0, 0, 0, 0, 0)));
|
|
|
|
// The robot reached N1 at step 3 -- goal-reached fires, path is cleared.
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, FollowLongPathRetrievesLtmNodesAcrossMultipleIterations)
|
|
{
|
|
// Same scenario as the previous test but with 10 path nodes and 8 of them
|
|
// in LTM. The path-retrieval loop is capped at kRGBDMaxLocalRetrieved=2 per
|
|
// process() iteration, so the LTM nodes are pulled back into WM gradually
|
|
// over multiple ticks as the retrieval window walks along the path.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0"; // disable retrieval during setup
|
|
reinit(params);
|
|
|
|
std::vector<int> ids;
|
|
for(int i = 0; i < 10; ++i)
|
|
{
|
|
ASSERT_TRUE(process());
|
|
ids.push_back(rtabmap_->getLastLocationId());
|
|
}
|
|
// The 8 oldest nodes have been pushed to LTM; the 2 most recent remain in
|
|
// WM (N9) and STM (N10).
|
|
int initialLtm = 0;
|
|
for(int i = 0; i < 8; ++i) if(rtabmap_->getMemory()->isInLTM(ids[i])) ++initialLtm;
|
|
ASSERT_EQ(initialLtm, 8);
|
|
|
|
ParametersMap updated;
|
|
updated[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
updated[Parameters::kRtabmapMemoryThr()] = "0";
|
|
rtabmap_->parseParameters(updated);
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(ids[0], /*global=*/true));
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 10u);
|
|
|
|
auto countLoadedFromIds = [&]() {
|
|
int c = 0;
|
|
for(int id : ids)
|
|
{
|
|
if(rtabmap_->getMemory()->isInWM(id) || rtabmap_->getMemory()->isInSTM(id))
|
|
++c;
|
|
}
|
|
return c;
|
|
};
|
|
|
|
// Initial: N9 in WM + N10 in STM. N1..N8 still in LTM.
|
|
EXPECT_EQ(countLoadedFromIds(), 2);
|
|
|
|
// Robot walks 9 steps from (9, 0) down to (1, 0). Each of the first 4 ticks
|
|
// retrieves 2 more LTM path nodes (8 LTM nodes / cap=2 -> 4 ticks). After
|
|
// that, all path nodes are in WM and the remaining ticks just follow the
|
|
// path until kRGBDGoalReachedRadius triggers clearPath(1).
|
|
int prev = countLoadedFromIds();
|
|
for(int step = 1; step <= 9; ++step)
|
|
{
|
|
ASSERT_TRUE(processWith(Transform(float(10 - step), 0, 0, 0, 0, 0)));
|
|
const int now = countLoadedFromIds();
|
|
if(step <= 4)
|
|
{
|
|
// The retrieval window advances by 2 nodes per iteration.
|
|
EXPECT_EQ(now - prev, 2) << "step " << step << " expected +2 retrievals";
|
|
}
|
|
else
|
|
{
|
|
// No more LTM nodes to retrieve.
|
|
EXPECT_EQ(now, 10) << "step " << step;
|
|
}
|
|
prev = now;
|
|
if(rtabmap_->getPathStatus() != 0) break;
|
|
}
|
|
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
EXPECT_EQ(countLoadedFromIds(), 10);
|
|
}
|
|
|
|
namespace {
|
|
// Adds a chain of "real" nodes separated by @p intermediatesBetween intermediate
|
|
// nodes. Real nodes are placed at integer x = 1, 2, ..., real_count. Intermediates
|
|
// sit at fractional positions between consecutive real nodes. Returns the ids of
|
|
// the real nodes in order. Uses const_cast on getMemory() to access the
|
|
// non-const convertToIntermediate.
|
|
std::vector<int> buildChainWithIntermediates(
|
|
Rtabmap * rtabmap,
|
|
const cv::Mat & image,
|
|
const cv::Mat & covariance,
|
|
int realCount,
|
|
int intermediatesBetween)
|
|
{
|
|
Memory * mem = const_cast<Memory*>(rtabmap->getMemory());
|
|
std::vector<int> realIds;
|
|
int procId = 0;
|
|
for(int real = 0; real < realCount; ++real)
|
|
{
|
|
SensorData d(image); d.setId(++procId);
|
|
EXPECT_TRUE(rtabmap->process(d, Transform(float(real + 1), 0, 0, 0, 0, 0), covariance));
|
|
realIds.push_back(rtabmap->getLastLocationId());
|
|
if(real < realCount - 1)
|
|
{
|
|
for(int k = 1; k <= intermediatesBetween; ++k)
|
|
{
|
|
float frac = float(k) / float(intermediatesBetween + 1);
|
|
SensorData di(image); di.setId(++procId);
|
|
EXPECT_TRUE(rtabmap->process(di, Transform(float(real + 1) + frac, 0, 0, 0, 0, 0), covariance));
|
|
mem->convertToIntermediate(rtabmap->getLastLocationId());
|
|
}
|
|
}
|
|
}
|
|
return realIds;
|
|
}
|
|
} // namespace
|
|
|
|
TEST_F(RtabmapFixture, FollowPathWithIntermediateNodesSkipsThemAndReachesGoal)
|
|
{
|
|
// Variant of FollowPathAdvancesCurrentIndexAndReachesGoal with 2 intermediate
|
|
// nodes between every pair of real nodes (4 real + 6 intermediate = 10
|
|
// signatures). The planner must skip the intermediate nodes when building
|
|
// _path so the path-follow can run on real waypoints only -- updateGoalIndex
|
|
// refuses to follow paths that contain intermediates.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDLocalRadius()] = "0.5";
|
|
reinit(params);
|
|
|
|
const std::vector<int> realIds = buildChainWithIntermediates(
|
|
rtabmap_, image_, covariance_, /*realCount=*/4, /*intermediatesBetween=*/2);
|
|
ASSERT_EQ(realIds.size(), 4u);
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(realIds[0], /*global=*/false));
|
|
// Path must contain only real nodes (intermediates filtered out).
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 4u);
|
|
for(size_t i = 0; i < rtabmap_->getPath().size(); ++i)
|
|
{
|
|
const Signature * s = rtabmap_->getMemory()->getSignature(rtabmap_->getPath()[i].first);
|
|
ASSERT_NE(s, nullptr);
|
|
EXPECT_NE(s->getWeight(), -1) << "path[" << i << "] is intermediate";
|
|
}
|
|
EXPECT_EQ(rtabmap_->getPath().front().first, realIds[3]); // start at N4
|
|
EXPECT_EQ(rtabmap_->getPath().back().first, realIds[0]); // end at N1
|
|
EXPECT_EQ(rtabmap_->getPathCurrentGoalId(), realIds[2]); // one step ahead
|
|
|
|
// Walk three ticks at real-node poses: N3, N2, N1. updateGoalIndex must
|
|
// tick through path indices without aborting and clear the path on arrival.
|
|
ASSERT_TRUE(processWith(Transform(3.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 1u);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
ASSERT_TRUE(processWith(Transform(2.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathCurrentIndex(), 2u);
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 0);
|
|
|
|
ASSERT_TRUE(processWith(Transform(1.0f, 0, 0, 0, 0, 0)));
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, FollowLongPathWithIntermediateNodesRetrievesRealLtmNodes)
|
|
{
|
|
// Variant of FollowLongPathRetrievesLtmNodesAcrossMultipleIterations with
|
|
// 2 intermediate nodes between every pair of real nodes (10 real + 18
|
|
// intermediate = 28 signatures). Only the real nodes need to be retrieved
|
|
// during path-follow; the planner filters intermediates out of the path.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
reinit(params);
|
|
|
|
const std::vector<int> realIds = buildChainWithIntermediates(
|
|
rtabmap_, image_, covariance_, /*realCount=*/10, /*intermediatesBetween=*/2);
|
|
ASSERT_EQ(realIds.size(), 10u);
|
|
// Some real nodes have been pushed to LTM by memory management.
|
|
int initialLtm = 0;
|
|
for(int i = 0; i < 8; ++i) if(rtabmap_->getMemory()->isInLTM(realIds[i])) ++initialLtm;
|
|
ASSERT_GE(initialLtm, 1);
|
|
|
|
ParametersMap updated;
|
|
updated[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
updated[Parameters::kRtabmapMemoryThr()] = "0";
|
|
rtabmap_->parseParameters(updated);
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(realIds[0], /*global=*/true));
|
|
// The planner filters intermediate nodes -> path is only the 10 real ones.
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 10u);
|
|
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";
|
|
}
|
|
}
|
|
|
|
// Walk back along the path. Path-follow advances and eventually reaches N1.
|
|
for(int step = 1; step <= 9; ++step)
|
|
{
|
|
ASSERT_TRUE(processWith(Transform(float(10 - step), 0, 0, 0, 0, 0)));
|
|
if(rtabmap_->getPathStatus() != 0) break;
|
|
}
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
}
|
|
|
|
TEST(RtabmapTest, FollowLongPathWithIntermediateNodesInLocalizationMode)
|
|
{
|
|
// Localization-mode variant: build a 10-real + 18-intermediate chain in
|
|
// mapping mode, save to DB, reopen in localization mode, plan a global
|
|
// path. The planner skips intermediates so the robot can walk through the
|
|
// real nodes only.
|
|
const std::string dbPath = uniqueDbPath();
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
std::vector<int> realIds;
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
realIds = buildChainWithIntermediates(&rtabmap, image, cov,
|
|
/*realCount=*/10, /*intermediatesBetween=*/2);
|
|
rtabmap.close(true);
|
|
}
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
rtabmap.setInitialPose(Transform(10.0f, 0, 0, 0, 0, 0));
|
|
|
|
ParametersMap upd;
|
|
upd[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
rtabmap.parseParameters(upd);
|
|
|
|
ASSERT_TRUE(rtabmap.computePath(realIds[0], /*global=*/true));
|
|
ASSERT_EQ(rtabmap.getPath().size(), 10u);
|
|
|
|
int procId = 101;
|
|
for(int step = 0; step <= 9; ++step)
|
|
{
|
|
SensorData d(image); d.setId(++procId);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(10 - step), 0, 0, 0, 0, 0), cov));
|
|
if(rtabmap.getPathStatus() != 0) break;
|
|
}
|
|
EXPECT_EQ(rtabmap.getPathStatus(), 1);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, FollowLongPathInLocalizationModeRetrievesLtmNodes)
|
|
{
|
|
// Same long-path retrieval scenario as
|
|
// FollowLongPathRetrievesLtmNodesAcrossMultipleIterations, but with the
|
|
// planning happening in localization mode against a persisted map. Build a
|
|
// 10-node map in mapping mode, save it, then reopen in localization mode
|
|
// with aggressive memory pressure. Verify that path retrieval pulls LTM
|
|
// path nodes back into WM over multiple iterations.
|
|
const std::string dbPath = uniqueDbPath();
|
|
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
std::vector<int> ids;
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
for(int i = 0; i < 10; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i + 1);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i + 1), 0, 0, 0, 0, 0), cov));
|
|
ids.push_back(rtabmap.getLastLocationId());
|
|
}
|
|
rtabmap.close(true);
|
|
}
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false"; // localization mode
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
rtabmap.setInitialPose(Transform(10.0f, 0, 0, 0, 0, 0));
|
|
|
|
// Re-enable retrieval. Keep kRtabmapMemoryThr aggressive so each tick
|
|
// triggers forget -> local-map refresh, which is what propagates newly
|
|
// retrieved LTM path nodes into _optimizedPoses (in localization mode the
|
|
// refresh only runs when at least one signature was removed in the same
|
|
// tick). Now safe to keep on thanks to the updateGoalIndex null-check fix.
|
|
ParametersMap upd;
|
|
upd[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
rtabmap.parseParameters(upd);
|
|
|
|
ASSERT_TRUE(rtabmap.computePath(ids[0], /*global=*/true));
|
|
ASSERT_EQ(rtabmap.getPath().size(), 10u);
|
|
|
|
// Walk the robot from N10 (10, 0) down to N1 (1, 0). The first tick at
|
|
// the initial pose calibrates mapCorrection to identity; subsequent
|
|
// ticks move 1 m back per tick. After tick 10 the robot is at N1.
|
|
int procId = 101;
|
|
for(int step = 0; step <= 9; ++step)
|
|
{
|
|
SensorData d(image); d.setId(++procId);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(10 - step), 0, 0, 0, 0, 0), cov));
|
|
if(rtabmap.getPathStatus() != 0) break;
|
|
}
|
|
EXPECT_EQ(rtabmap.getPathStatus(), 1);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, LocalRadiusLimitsPathRetrievalToOneNodePerIteration)
|
|
{
|
|
// Same 10-node, 8-LTM scenario as the previous test, but with kRGBDLocalRadius
|
|
// reduced to 0.9 m -- *less* than the 1 m node spacing. The path-retrieval
|
|
// loop in Rtabmap::process breaks once distanceSoFar exceeds the radius, so
|
|
// only the *first* LTM node beyond the current path index is retrieved each
|
|
// tick. The cap kRGBDMaxLocalRetrieved=2 default is no longer the bottleneck:
|
|
// the local radius is. It takes 8 ticks to retrieve the 8 LTM nodes (vs.
|
|
// 4 ticks with the default 10 m radius).
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "1";
|
|
params[Parameters::kRtabmapMemoryThr()] = "1";
|
|
params[Parameters::kRGBDMaxLocalRetrieved()] = "0";
|
|
reinit(params);
|
|
|
|
std::vector<int> ids;
|
|
for(int i = 0; i < 10; ++i)
|
|
{
|
|
ASSERT_TRUE(process());
|
|
ids.push_back(rtabmap_->getLastLocationId());
|
|
}
|
|
int initialLtm = 0;
|
|
for(int i = 0; i < 8; ++i) if(rtabmap_->getMemory()->isInLTM(ids[i])) ++initialLtm;
|
|
ASSERT_EQ(initialLtm, 8);
|
|
|
|
ParametersMap updated;
|
|
updated[Parameters::kRGBDMaxLocalRetrieved()] = "2";
|
|
updated[Parameters::kRtabmapMemoryThr()] = "0";
|
|
updated[Parameters::kRGBDLocalRadius()] = "0.9"; // < node spacing -> radius caps retrieval
|
|
rtabmap_->parseParameters(updated);
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(ids[0], /*global=*/true));
|
|
ASSERT_EQ(rtabmap_->getPath().size(), 10u);
|
|
|
|
auto countLoaded = [&]() {
|
|
int c = 0;
|
|
for(int id : ids)
|
|
if(rtabmap_->getMemory()->isInWM(id) || rtabmap_->getMemory()->isInSTM(id)) ++c;
|
|
return c;
|
|
};
|
|
|
|
EXPECT_EQ(countLoaded(), 2);
|
|
|
|
int prev = countLoaded();
|
|
for(int step = 1; step <= 9; ++step)
|
|
{
|
|
ASSERT_TRUE(processWith(Transform(float(10 - step), 0, 0, 0, 0, 0)));
|
|
const int now = countLoaded();
|
|
if(step <= 8)
|
|
{
|
|
// Radius=0.9 m allows reaching exactly one new LTM node per tick.
|
|
EXPECT_EQ(now - prev, 1) << "step " << step << " expected +1 retrieval";
|
|
}
|
|
prev = now;
|
|
if(rtabmap_->getPathStatus() != 0) break;
|
|
}
|
|
EXPECT_EQ(rtabmap_->getPathStatus(), 1);
|
|
EXPECT_EQ(countLoaded(), 10);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ComputePathToUnknownTargetReturnsFalseAndClearsPath)
|
|
{
|
|
// graph::computePath returns an empty path for an unreachable target;
|
|
// Rtabmap::computePath then returns false and leaves the path empty.
|
|
process();
|
|
process();
|
|
EXPECT_FALSE(rtabmap_->computePath(99999, /*global=*/false));
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ComputePathClearsPreviousPath)
|
|
{
|
|
// First plan a valid path, then plan a path with an invalid target. The
|
|
// previous path must be cleared at the start of the new computePath call.
|
|
process();
|
|
const int N1 = rtabmap_->getLastLocationId();
|
|
process();
|
|
process();
|
|
process();
|
|
|
|
ASSERT_TRUE(rtabmap_->computePath(N1, /*global=*/false));
|
|
ASSERT_GT(rtabmap_->getPath().size(), 0u);
|
|
|
|
rtabmap_->computePath(99999, /*global=*/false);
|
|
EXPECT_EQ(rtabmap_->getPath().size(), 0u);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// globalBundleAdjustment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, GlobalBundleAdjustmentReturnsFalseWhenGraphEmpty)
|
|
{
|
|
// No process() calls -> _optimizedPoses and _constraints both empty.
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams());
|
|
EXPECT_FALSE(rtabmap.globalBundleAdjustment(/*optimizerType=*/0,
|
|
/*rematchFeatures=*/false, /*iterations=*/1, /*pixelVariance=*/1.0f));
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// cleanupLocalGrids (Rtabmap wrapper)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, CleanupLocalGridsForwardsToMemory)
|
|
{
|
|
// Rtabmap::cleanupLocalGrids is a thin wrapper around Memory::cleanupLocalGrids
|
|
// (exhaustively tested in test_memory.cpp). The fixture's Rtabmap.init(params)
|
|
// gives Memory an in-memory database, so the call succeeds; with no signature
|
|
// carrying obstacle cells, nothing is filtered and the function returns 0.
|
|
process();
|
|
std::map<int, Transform> poses{{rtabmap_->getLastLocationId(), Transform::getIdentity()}};
|
|
cv::Mat map(4, 4, CV_8UC1, cv::Scalar(0));
|
|
EXPECT_EQ(rtabmap_->cleanupLocalGrids(poses, map, 0.0f, 0.0f, 0.1f), 0);
|
|
}
|
|
|
|
TEST(RtabmapTest, CleanupLocalGridsReturnsMinusOneWithoutMemory)
|
|
{
|
|
// Default-constructed Rtabmap has no Memory yet -> wrapper short-circuits.
|
|
Rtabmap rtabmap;
|
|
std::map<int, Transform> poses{{1, Transform::getIdentity()}};
|
|
cv::Mat map(4, 4, CV_8UC1, cv::Scalar(0));
|
|
EXPECT_EQ(rtabmap.cleanupLocalGrids(poses, map, 0.0f, 0.0f, 0.1f), -1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RGBD/OptimizeMaxError branches inside Rtabmap::process()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace {
|
|
// Parameter set tuned to drive a feature-based loop closure inside
|
|
// Rtabmap::process() without running real feature extraction. Pre-baked
|
|
// keypoints+descriptors are fed via SensorData (kMemUseOdomFeatures=true);
|
|
// epipolar verification and proximity detection are disabled so the only
|
|
// channel that can fire a loop closure is the appearance-based Bayes filter.
|
|
ParametersMap badLoopClosureParams()
|
|
{
|
|
ParametersMap params;
|
|
params.insert({Parameters::kKpMaxFeatures(), "8"});
|
|
params.insert({Parameters::kKpIncrementalFlann(), "true"});
|
|
params.insert({Parameters::kMemUseOdomFeatures(), "true"});
|
|
params.insert({Parameters::kMemRehearsalSimilarity(), "1.0"});
|
|
params.insert({Parameters::kMemBinDataKept(), "false"});
|
|
params.insert({Parameters::kMemBadSignaturesIgnored(), "false"});
|
|
params.insert({Parameters::kMemSTMSize(), "1"});
|
|
params.insert({Parameters::kRGBDEnabled(), "true"});
|
|
params.insert({Parameters::kRGBDLinearUpdate(), "0.0"});
|
|
params.insert({Parameters::kRGBDAngularUpdate(), "0.0"});
|
|
params.insert({Parameters::kVhEpEnabled(), "false"}); // skip epipolar verification
|
|
params.insert({Parameters::kRGBDProximityBySpace(), "false"}); // Bayes only
|
|
// Allow visual registration to succeed with our small feature count
|
|
// (default kVisMinInliers=20 would reject 8 features).
|
|
params.insert({Parameters::kVisMinInliers(), "6"});
|
|
// Skip bundle adjustment which can NAN out on these synthetic features.
|
|
params.insert({Parameters::kVisBundleAdjustment(), "0"});
|
|
return params;
|
|
}
|
|
|
|
// SensorData with k distinct keypoints (non-degenerate 2D/3D so the visual
|
|
// registration can compute a valid relative transform between matched frames)
|
|
// and one-hot descriptors keyed by @p featSlot. Frames with the same featSlot
|
|
// produce identical visual words; different featSlots produce disjoint words
|
|
// so the Bayes filter does not auto-match them.
|
|
SensorData makeFeaturesData(int id, int featSlot = 0, int k = 8)
|
|
{
|
|
const int kSlots = 32;
|
|
// featSlot * 8 + row writes into a kSlots*8-wide descriptor matrix below;
|
|
// an out-of-bounds slot silently corrupts heap because cv::Mat::at skips
|
|
// bounds checks in release builds. Catch it at the test boundary.
|
|
UASSERT(featSlot >= 0 && featSlot < kSlots);
|
|
cv::Mat image(64, 64, CV_8UC1, cv::Scalar(128));
|
|
CameraModel camera(50.0, 50.0, 32.0, 32.0, CameraModel::opticalRotation(), 0.0, cv::Size(64, 64));
|
|
SensorData data(image, camera); data.setId(id);
|
|
std::vector<cv::KeyPoint> kpts;
|
|
std::vector<cv::Point3f> pts3;
|
|
// 3D points are stored in the base/body frame: x forward, y left, z up.
|
|
// They are projected via fx=fy=50, cx=cy=32 so the (u, v) keypoints are
|
|
// consistent with the (x, y, z) 3D positions, otherwise PnP can't solve.
|
|
// u = cx - fx * (y / x) => y = -(u - cx) * x / fx
|
|
// v = cy - fy * (z / x) => z = -(v - cy) * x / fy
|
|
const float positions[8][5] = {
|
|
// u, v, x_base, y_base, z_base
|
|
{10, 10, 1.5f, 0.66f, 0.66f},
|
|
{50, 10, 1.5f, -0.54f, 0.66f},
|
|
{50, 50, 1.5f, -0.54f, -0.54f},
|
|
{10, 50, 1.5f, 0.66f, -0.54f},
|
|
{30, 30, 1.0f, 0.04f, 0.04f},
|
|
{20, 40, 1.2f, 0.29f, -0.19f},
|
|
{40, 20, 1.2f, -0.19f, 0.29f},
|
|
{40, 50, 1.5f, -0.24f, -0.54f},
|
|
};
|
|
for(int i = 0; i < k && i < 8; ++i)
|
|
{
|
|
kpts.push_back(cv::KeyPoint(positions[i][0], positions[i][1], 1.f));
|
|
pts3.push_back(cv::Point3f(positions[i][2], positions[i][3], positions[i][4]));
|
|
}
|
|
cv::Mat descriptors = cv::Mat::zeros(k, kSlots * 8, CV_32F);
|
|
for(int row = 0; row < k; ++row)
|
|
{
|
|
descriptors.at<float>(row, featSlot * 8 + row) = 1000.0f;
|
|
}
|
|
data.setFeatures(kpts, pts3, descriptors);
|
|
return data;
|
|
}
|
|
// Variant where each feature row can come from a different slot. Used to
|
|
// build partial-match signatures (e.g., 4 features from slot 30 + 4 features
|
|
// from a unique slot) so multiple nodes have positive raw likelihood at the
|
|
// same time -- adjustLikelihood's Angeli outlier check needs >1 positive
|
|
// sample to produce a non-zero stdDev.
|
|
SensorData makeFeaturesMixedSlots(int id, const std::vector<int> & slotPerRow)
|
|
{
|
|
const int k = (int)slotPerRow.size();
|
|
UASSERT(k <= 8);
|
|
const int kSlots = 32;
|
|
cv::Mat image(64, 64, CV_8UC1, cv::Scalar(128));
|
|
CameraModel camera(50.0, 50.0, 32.0, 32.0, CameraModel::opticalRotation(), 0.0, cv::Size(64, 64));
|
|
SensorData data(image, camera); data.setId(id);
|
|
std::vector<cv::KeyPoint> kpts;
|
|
std::vector<cv::Point3f> pts3;
|
|
const float positions[8][5] = {
|
|
{10, 10, 1.5f, 0.66f, 0.66f},
|
|
{50, 10, 1.5f, -0.54f, 0.66f},
|
|
{50, 50, 1.5f, -0.54f, -0.54f},
|
|
{10, 50, 1.5f, 0.66f, -0.54f},
|
|
{30, 30, 1.0f, 0.04f, 0.04f},
|
|
{20, 40, 1.2f, 0.29f, -0.19f},
|
|
{40, 20, 1.2f, -0.19f, 0.29f},
|
|
{40, 50, 1.5f, -0.24f, -0.54f},
|
|
};
|
|
for(int i = 0; i < k; ++i)
|
|
{
|
|
kpts.push_back(cv::KeyPoint(positions[i][0], positions[i][1], 1.f));
|
|
pts3.push_back(cv::Point3f(positions[i][2], positions[i][3], positions[i][4]));
|
|
}
|
|
cv::Mat descriptors = cv::Mat::zeros(k, kSlots * 8, CV_32F);
|
|
for(int row = 0; row < k; ++row)
|
|
{
|
|
descriptors.at<float>(row, slotPerRow[row] * 8 + row) = 1000.0f;
|
|
}
|
|
data.setFeatures(kpts, pts3, descriptors);
|
|
return data;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST(RtabmapTest, ProcessRejectsBadLoopClosureWhenMaxErrorExceeded)
|
|
{
|
|
// Build three signatures with identical visual features but the third is
|
|
// 10 m away in odometry. The Bayes filter detects a loop closure between
|
|
// the new frame and an old one, but the chain says they are 10 m apart -->
|
|
// huge optimization error ratio. kRGBDOptimizeMaxError=1 rejects the link.
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "1.0";
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05"; // accept low-likelihood hypotheses
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1"; // less weight on virtual place -> appearance likelihood dominates
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
// 10-node chain. N4 is the "match" target for N10 (8/8 features in slot 30).
|
|
// N3 and N5 are partial matches (2/8 features in slot 30) so the
|
|
// Rtabmap::adjustLikelihood Angeli outlier check has multiple positive raw
|
|
// likelihoods to compute non-zero variance from -- otherwise N4's single
|
|
// peak gets flattened to the same value as every other node.
|
|
// The chain accumulates 6 m of odometry between N4 and N10 but the loop
|
|
// closure registration on identical features returns ~identity, producing
|
|
// a ~6 m optimization error that exceeds kRGBDOptimizeMaxError=1.
|
|
const int kMatchSlot = 30;
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
// N3: 2/8 features in match slot, 6/8 unique -> partial match.
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(3, {kMatchSlot, kMatchSlot, 23, 23, 23, 23, 23, 23}),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
// N4: 8/8 features in match slot -> full match.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
// N5: 2/8 features in match slot, 6/8 unique -> partial match.
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(5, {kMatchSlot, kMatchSlot, 25, 25, 25, 25, 25, 25}),
|
|
Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
for(int i = 6; i <= 9; ++i)
|
|
{
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(i, /*featSlot=*/i), Transform(float(i - 1), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
// N10 reuses the match slot -> Bayes filter matches N10 back to N4.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(10, /*featSlot=*/kMatchSlot), Transform(9.0f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// Verify the rejection specifically came from the OptimizeMaxError path:
|
|
// kLoopOptimization_max_error_ratio (linear) and ..._max_ang_error_ratio
|
|
// (angular) reflect the per-edge residuals after optimization. The gate
|
|
// rejects if EITHER exceeds kRGBDOptimizeMaxError, so the test asserts
|
|
// at least one fired. g2o/GTSAM/Ceres on this graph can satisfy the bad
|
|
// loop closure by rotating the chain edges (each chain edge still has
|
|
// ~1m translation in its local frame even when the chain curls back to
|
|
// N4) -- the linear residuals stay tight but the angular residuals
|
|
// explode. TORO's gradient solver leaves the linear residual exposed
|
|
// instead. Either path is a valid rejection.
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itRatio = stats.find(Statistics::kLoopOptimization_max_error_ratio());
|
|
auto itAngRatio = stats.find(Statistics::kLoopOptimization_max_ang_error_ratio());
|
|
ASSERT_NE(itRatio, stats.end());
|
|
ASSERT_NE(itAngRatio, stats.end());
|
|
EXPECT_TRUE(itRatio->second > 1.0f || itAngRatio->second > 1.0f)
|
|
<< "linear or angular max-error ratio must exceed kRGBDOptimizeMaxError"
|
|
<< " (linear=" << itRatio->second << ", angular=" << itAngRatio->second << ")";
|
|
auto itRej = stats.find(Statistics::kLoopRejectedHypothesis());
|
|
ASSERT_NE(itRej, stats.end());
|
|
EXPECT_FLOAT_EQ(itRej->second, 1.0f);
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
// And the would-be loop closure link was removed from Memory.
|
|
const Signature * sLast = rtabmap.getMemory()->getSignature(rtabmap.getLastLocationId());
|
|
ASSERT_NE(sLast, nullptr);
|
|
for(const auto & kv : sLast->getLinks())
|
|
{
|
|
EXPECT_NE(kv.second.type(), Link::kGlobalClosure);
|
|
}
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// In localization mode the OptimizeMaxError check is fed by the odom cache
|
|
// chain rather than by the mapping graph: a first loop closure on N4 anchors
|
|
// the localization, then a chain of unique-feature nodes builds up odometry
|
|
// distance, and a second loop closure that pretends to land on N4 again is
|
|
// inconsistent with the accumulated chain and gets rejected by
|
|
// kRGBDOptimizeMaxError.
|
|
TEST(RtabmapTest, ProcessRejectsBadLoopClosureInLocalizationModeViaOptimizeMaxError)
|
|
{
|
|
const std::string dbPath = uniqueDbPath();
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// --- Mapping phase: build a 9-node map with the same partial-match
|
|
// scaffolding used by ProcessRejectsBadLoopClosureWhenMaxErrorExceeded
|
|
// (N3 and N5 have 2/8 features in kMatchSlot) so the Bayes filter has
|
|
// a non-degenerate likelihood distribution to score N10 / N20 against.
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0"; // disable while mapping
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(3, {kMatchSlot, kMatchSlot, 23, 23, 23, 23, 23, 23}),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(5, {kMatchSlot, kMatchSlot, 25, 25, 25, 25, 25, 25}),
|
|
Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
for(int i = 6; i <= 9; ++i)
|
|
{
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(i, /*featSlot=*/i), Transform(float(i - 1), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
rtabmap.close(true);
|
|
}
|
|
|
|
// --- Localization phase: reopen non-incremental and walk forward in odom.
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "1.0";
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
// The odom cache must span the entire localization session (N10..N20 = 11
|
|
// nodes) so the second loop closure has the full chain available when
|
|
// OptimizeMaxError evaluates the would-be edge.
|
|
params[Parameters::kRGBDMaxOdomCacheSize()] = "30";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
// Anchor localization near N4 so the first loop closure is consistent.
|
|
rtabmap.setInitialPose(Transform(3.0f, 0, 0, 0, 0, 0));
|
|
|
|
// N10 and N11: identical features to N4 at (essentially) the same odom
|
|
// pose. In localization mode, re-localization requires two consecutive
|
|
// matching hypotheses on the same node before the loop closure is
|
|
// committed -- N10 alone is rejected even though its likelihood on N4
|
|
// is high; N11 then confirms the hypothesis and the link is accepted.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(10, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(11, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 4);
|
|
|
|
// N12..N19: unique features, no loop. Odometry walks 1 m per step in
|
|
// the same direction so by N19 the chain is 8 m past the anchor.
|
|
// Slots 12..19 are not used during mapping (mapping uses slots 0, 1, 23,
|
|
// 25, 30, 6..9) so each step adds fresh words to the dictionary without
|
|
// matching anything. Slots must stay < kSlots=32 in makeFeaturesData
|
|
// to keep descriptor writes inside the cv::Mat allocation.
|
|
for(int i = 12; i <= 19; ++i)
|
|
{
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesData(i, /*featSlot=*/i),
|
|
Transform(float(i - 12 + 4), 0, 0, 0, 0, 0), cov));
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 0) << "unexpected loop at i=" << i;
|
|
}
|
|
|
|
// N20: identical features to N4 again, but odometry says we're 9 m
|
|
// past N4. The loop-closure registration on identical features returns
|
|
// ~identity, so OptimizeMaxError sees a ~9 m disagreement between the
|
|
// odom-cache chain and the proposed loop edge -> rejected.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(20, /*featSlot=*/kMatchSlot), Transform(12.0f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// Either linear or angular ratio can trigger rejection (see comment
|
|
// on ProcessRejectsBadLoopClosureWhenMaxErrorExceeded).
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itRatio = stats.find(Statistics::kLoopOptimization_max_error_ratio());
|
|
auto itAngRatio = stats.find(Statistics::kLoopOptimization_max_ang_error_ratio());
|
|
ASSERT_NE(itRatio, stats.end());
|
|
ASSERT_NE(itAngRatio, stats.end());
|
|
EXPECT_TRUE(itRatio->second > 1.0f || itAngRatio->second > 1.0f)
|
|
<< "linear or angular max-error ratio must exceed kRGBDOptimizeMaxError"
|
|
<< " (linear=" << itRatio->second << ", angular=" << itAngRatio->second << ")";
|
|
auto itRej = stats.find(Statistics::kLoopRejectedHypothesis());
|
|
ASSERT_NE(itRej, stats.end());
|
|
EXPECT_FLOAT_EQ(itRej->second, 1.0f);
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
// Repair branch of kRGBDOptimizeMaxError inside Rtabmap::process(): when the
|
|
// SAME loop closure edge is the max-error link of the graph two iterations in
|
|
// a row, kRGBDOptimizeMaxErrorRepairRadius>0 lets process() drop the offending
|
|
// edge instead of rejecting the new loop closure. The strategy here is:
|
|
//
|
|
// Phase 1 - OptimizeMaxError disabled. Build a chain N1..N8 (N3 = full
|
|
// kMatchSlot target with two pairs of partial flanks around it),
|
|
// then inject a wrong (N8,N2) loop closure via addLink (identity
|
|
// transform when the chain says 6 m apart). The link survives
|
|
// because OptimizeMaxError=0 short-circuits the gate.
|
|
// Phase 2 - parseParameters() enables OptimizeMaxError + repair. The robot
|
|
// backs up (N9..N12) toward N3's pose, then N13 with partial
|
|
// kMatchSlot features triggers a Bayes-detected (N13,N3) loop.
|
|
// The dominant max-error edge is the planted (N8,N2) link, so
|
|
// the loop is rejected and _lastRejectedLoopClosureIds <- (2,8).
|
|
// After a unique-feature intermediate N14, N15 lands at N3's
|
|
// pose with full kMatchSlot, triggering a second (N15,N3) loop.
|
|
// The same (N8,N2) edge is again the max-error link, so the
|
|
// repair branch fires and removes the bad link.
|
|
//
|
|
// The optimizer's residual-distribution behavior matters here: g2o and GTSAM
|
|
// both place the bad link as the dominant max-error edge across both phase-2
|
|
// iterations. TORO's simpler gradient solver does not converge the same way
|
|
// and the test does not hold for it (see ProcessRepairsGraphAfterRepeatedBadLoopClosure*).
|
|
static void runProcessRepairsGraphAfterRepeatedBadLoopClosure(int optimizerStrategy, bool gtsamIncremental = false)
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kOptimizerStrategy()] = uNumber2Str(optimizerStrategy);
|
|
if(gtsamIncremental)
|
|
{
|
|
params[Parameters::kGTSAMIncremental()] = "true";
|
|
}
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0.0"; // disabled in phase 1
|
|
params[Parameters::kRGBDOptimizeMaxErrorRepairRadius()] = "0.0";
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
// Disable TF-IDF likelihood: with all signatures the same size, the simple
|
|
// compareTo metric (matched_pairs / max(words)) gives N3 a clean peak in
|
|
// phase-2 queries without the TF-IDF dilution that otherwise flattens the
|
|
// likelihood vector against the bad-link prior.
|
|
params[Parameters::kKpTfIdfLikelihoodUsed()] = "false";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// N1, N2: unique features. N2 will be one end of the bad link.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
// N3..N5: target + two partial-match flanks for phase-2 Bayes variance.
|
|
// The flanks give adjustLikelihood >= 3 positive samples around N3 so it
|
|
// emits a real peak instead of a uniform posterior.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(3, /*featSlot=*/kMatchSlot), Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(4, {kMatchSlot, kMatchSlot, 23, 23, 23, 23, 23, 23}),
|
|
Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(5, {kMatchSlot, kMatchSlot, 24, 24, 24, 24, 24, 24}),
|
|
Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
// N6, N7: more 2/8 kMatchSlot partials. Five partials in total (N4..N7
|
|
// plus N13 in phase 2) give adjustLikelihood enough variance to produce a
|
|
// sharp peak on N3 - the prior propagation through the planted bad link
|
|
// concentrates posterior on N8/N2, so the appearance peak on N3 must be
|
|
// strong enough to overcome it.
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(6, {kMatchSlot, kMatchSlot, 26, 26, 26, 26, 26, 26}),
|
|
Transform(5.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(7, {kMatchSlot, kMatchSlot, 27, 27, 27, 27, 27, 27}),
|
|
Transform(6.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(8, /*featSlot=*/8), Transform(7.0f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// Inject the bad loop closure (N8,N2): identity transform claimed across
|
|
// a 6 m chain gap. addLink() bypasses the residual gate because
|
|
// OptimizeMaxError=0. The information matrix is set LOWER than the odom
|
|
// links' info (=1/cov=100): with the loop's low confidence the optimizer
|
|
// preserves the odom chain rather than satisfying the loop, so the loop's
|
|
// residual stays at ~6 m and dominates the max-error ranking across
|
|
// phase-2 iterations -> _lastRejectedLoopClosureIds stays on (N8,N2)
|
|
// -> repair fires on the second iteration.
|
|
const Link badLink(8, 2, Link::kGlobalClosure, Transform::getIdentity(),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 1.0);
|
|
ASSERT_TRUE(rtabmap.addLink(badLink));
|
|
|
|
// Phase 2: enable OptimizeMaxError + repair.
|
|
ParametersMap params2 = params;
|
|
params2[Parameters::kRGBDOptimizeMaxError()] = "1.0";
|
|
params2[Parameters::kRGBDOptimizeMaxErrorRepairRadius()] = "1.0";
|
|
rtabmap.parseParameters(params2);
|
|
|
|
// Back up smoothly from N8 (odom x=7) toward N3 (odom x=2).
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(9, /*featSlot=*/9), Transform(6.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(10, /*featSlot=*/10), Transform(5.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(11, /*featSlot=*/11), Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(12, /*featSlot=*/12), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
// N13: partial kMatchSlot (6/8 rows + 2 unique) so it can loop-close to
|
|
// N3 via PnP (6 inliers >= kVisMinInliers=6) without becoming an 8/8 twin
|
|
// of N3 - that would tie with N3 in N15's Bayes likelihood and flatten
|
|
// the adjusted posterior.
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(13, {kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, 13, 13}),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
{
|
|
const auto & s = rtabmap.getStatistics().data();
|
|
auto itRej = s.find(Statistics::kLoopRejectedHypothesis());
|
|
ASSERT_NE(itRej, s.end());
|
|
EXPECT_FLOAT_EQ(itRej->second, 1.0f) << "first loop must be rejected to arm repair";
|
|
EXPECT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
auto itFrom = s.find(Statistics::kLoopOptimization_max_error_from_id());
|
|
auto itTo = s.find(Statistics::kLoopOptimization_max_error_to_id());
|
|
ASSERT_NE(itFrom, s.end());
|
|
ASSERT_NE(itTo, s.end());
|
|
// The max-error edge must be the planted (N8,N2) loop so the second
|
|
// iteration sees the same edge -> _lastRejectedLoopClosureIds matches.
|
|
EXPECT_EQ(int(itFrom->second), 2);
|
|
EXPECT_EQ(int(itTo->second), 8);
|
|
}
|
|
|
|
// N14: unique features intermediate (forward step). Doesn't trigger a
|
|
// loop closure, so _lastRejectedLoopClosureIds is preserved from N13.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(14, /*featSlot=*/14), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
// N15: second kMatchSlot query -> (N15,N3) loop attempt. Same dominant
|
|
// max-error edge (N8,N2), same _lastRejectedLoopClosureIds -> repair fires.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(15, /*featSlot=*/kMatchSlot),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// Verify repair fired: kLoop/Optimization_max_error_removed_* are only
|
|
// populated when repairGraph() actually removed at least one link.
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itRemoved = stats.find(Statistics::kLoopOptimization_max_error_removed_count());
|
|
ASSERT_NE(itRemoved, stats.end());
|
|
EXPECT_GE(itRemoved->second, 1.0f);
|
|
auto itRemovedFrom = stats.find(Statistics::kLoopOptimization_max_error_removed_from_id());
|
|
ASSERT_NE(itRemovedFrom, stats.end());
|
|
auto itRemovedTo = stats.find(Statistics::kLoopOptimization_max_error_removed_to_id());
|
|
ASSERT_NE(itRemovedTo, stats.end());
|
|
// The removed edge must be the (N8,N2) planted bad link (ids stored in
|
|
// either direction depending on Memory's link convention).
|
|
const std::pair<int, int> removedIds{int(itRemovedFrom->second), int(itRemovedTo->second)};
|
|
EXPECT_TRUE(removedIds == std::make_pair(8, 2) || removedIds == std::make_pair(2, 8))
|
|
<< "got (" << removedIds.first << "," << removedIds.second << ")";
|
|
|
|
// New loop closure accepted, and the bad (N8,N2) link is gone from Memory.
|
|
EXPECT_NE(rtabmap.getLoopClosureId(), 0);
|
|
const Signature * sN8 = rtabmap.getMemory()->getSignature(8);
|
|
ASSERT_NE(sN8, nullptr);
|
|
EXPECT_EQ(sN8->getLinks().count(2), 0u) << "(N8,N2) bad link must be repaired away";
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, ProcessRepairsGraphAfterRepeatedBadLoopClosureG2O)
|
|
{
|
|
if(!Optimizer::isAvailable(Optimizer::kTypeG2O))
|
|
{
|
|
GTEST_SKIP() << "g2o optimizer not available in this build";
|
|
}
|
|
runProcessRepairsGraphAfterRepeatedBadLoopClosure(Optimizer::kTypeG2O);
|
|
}
|
|
|
|
TEST(RtabmapTest, ProcessRepairsGraphAfterRepeatedBadLoopClosureGTSAM)
|
|
{
|
|
if(!Optimizer::isAvailable(Optimizer::kTypeGTSAM))
|
|
{
|
|
GTEST_SKIP() << "GTSAM optimizer not available in this build";
|
|
}
|
|
runProcessRepairsGraphAfterRepeatedBadLoopClosure(Optimizer::kTypeGTSAM);
|
|
}
|
|
|
|
TEST(RtabmapTest, ProcessRepairsGraphAfterRepeatedBadLoopClosureGTSAMIncremental)
|
|
{
|
|
if(!Optimizer::isAvailable(Optimizer::kTypeGTSAM))
|
|
{
|
|
GTEST_SKIP() << "GTSAM optimizer not available in this build";
|
|
}
|
|
// iSAM2 incremental optimization should still surface the planted bad link
|
|
// as the dominant max-error edge across both rejection iterations so the
|
|
// repair branch fires.
|
|
runProcessRepairsGraphAfterRepeatedBadLoopClosure(Optimizer::kTypeGTSAM, /*gtsamIncremental=*/true);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setUserData round-trip
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, SetUserDataRoundTripsThroughGetSignatureCopy)
|
|
{
|
|
// Round-trip user data attached to a node via setUserData(id) and read it
|
|
// back through getSignatureCopy(..., userData=true).
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
SensorData data(image); data.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(data, Transform(0, 0, 0, 0, 0, 0), cov));
|
|
const int id = rtabmap.getLastLocationId();
|
|
|
|
// Attach a deterministic payload after processing.
|
|
const std::vector<unsigned char> payload = {0x42, 0xAB, 0x00, 0x7F, 0x11};
|
|
const cv::Mat userData(1, int(payload.size()), CV_8UC1, const_cast<unsigned char *>(payload.data()));
|
|
EXPECT_TRUE(rtabmap.setUserData(id, userData.clone()));
|
|
|
|
// Read it back with userData=true.
|
|
const Signature s = rtabmap.getSignatureCopy(id, /*images=*/false,
|
|
/*scan=*/false, /*userData=*/true, /*occupancyGrid=*/false,
|
|
/*withWords=*/false, /*withGlobalDescriptors=*/false);
|
|
const cv::Mat readBack = s.sensorData().userDataRaw();
|
|
ASSERT_EQ(readBack.total(), payload.size());
|
|
for(size_t i = 0; i < payload.size(); ++i)
|
|
{
|
|
EXPECT_EQ(readBack.data[i], payload[i]) << "byte " << i;
|
|
}
|
|
|
|
// id=0 falls back to "last working signature".
|
|
const cv::Mat payload2(1, 3, CV_8UC1, cv::Scalar(0xCC));
|
|
EXPECT_TRUE(rtabmap.setUserData(0, payload2));
|
|
const Signature s2 = rtabmap.getSignatureCopy(id, /*images=*/false,
|
|
/*scan=*/false, /*userData=*/true, /*occupancyGrid=*/false,
|
|
/*withWords=*/false, /*withGlobalDescriptors=*/false);
|
|
EXPECT_EQ(s2.sensorData().userDataRaw().total(), 3u);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, SetUserDataReturnsFalseWithoutMemory)
|
|
{
|
|
Rtabmap rtabmap;
|
|
// No init() -> no memory.
|
|
EXPECT_FALSE(rtabmap.setUserData(1, cv::Mat(1, 4, CV_8UC1)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getNodesInRadius
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GetNodesInRadiusByPoseFiltersByDistance)
|
|
{
|
|
// Build a 5-node straight-line chain at x=1..5.
|
|
for(int i = 0; i < 5; ++i) process();
|
|
|
|
// Query around (3,0,0) with a 1.5 m radius -> {N2, N3, N4}.
|
|
std::map<int, float> distsSqr;
|
|
auto poses = rtabmap_->getNodesInRadius(Transform(3.0f, 0, 0, 0, 0, 0), 1.5f, 0, &distsSqr);
|
|
std::set<int> ids;
|
|
for(const auto & kv : poses) ids.insert(kv.first);
|
|
EXPECT_EQ(ids, (std::set<int>{2, 3, 4}));
|
|
// N3 is at distance 0, N2 and N4 at distance 1.
|
|
EXPECT_FLOAT_EQ(distsSqr.at(3), 0.0f);
|
|
EXPECT_FLOAT_EQ(distsSqr.at(2), 1.0f);
|
|
EXPECT_FLOAT_EQ(distsSqr.at(4), 1.0f);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetNodesInRadiusByPoseRespectsKCap)
|
|
{
|
|
for(int i = 0; i < 5; ++i) process();
|
|
|
|
// Query around (3,0,0) with a 10 m radius (covers all 5) but k=2 -> the 2
|
|
// closest: N3 (d=0) and either N2 or N4 (d=1).
|
|
auto poses = rtabmap_->getNodesInRadius(Transform(3.0f, 0, 0, 0, 0, 0), 10.0f, 2);
|
|
EXPECT_EQ(poses.size(), 2u);
|
|
EXPECT_TRUE(poses.count(3));
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetNodesInRadiusByNodeIdQueriesAroundThatNode)
|
|
{
|
|
for(int i = 0; i < 5; ++i) process();
|
|
|
|
// Around N3 (x=3), radius 1.5 m: candidates exclude the query itself, so
|
|
// the result is the immediate neighbors N2 (d=1) and N4 (d=1).
|
|
auto poses = rtabmap_->getNodesInRadius(/*nodeId=*/3, 1.5f);
|
|
std::set<int> ids;
|
|
for(const auto & kv : poses) ids.insert(kv.first);
|
|
EXPECT_EQ(ids, (std::set<int>{2, 4})) << "graph::findNearestNodes(nodeId,...) excludes nodeId itself";
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, GetNodesInRadiusUnknownNodeReturnsEmpty)
|
|
{
|
|
for(int i = 0; i < 3; ++i) process();
|
|
auto poses = rtabmap_->getNodesInRadius(/*nodeId=*/999, 10.0f);
|
|
EXPECT_TRUE(poses.empty());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// generateDOTGraph
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GenerateDOTGraphWritesDotFile)
|
|
{
|
|
for(int i = 0; i < 4; ++i) process();
|
|
|
|
const std::string dotPath = test::tempPath(uFormat("rtabmap_test_dot_%d.dot", test::getPid()));
|
|
UFile::erase(dotPath.c_str());
|
|
rtabmap_->generateDOTGraph(dotPath, /*id=*/0, /*margin=*/5);
|
|
|
|
ASSERT_TRUE(UFile::exists(dotPath));
|
|
// Smoke-check: graphviz file begins with "digraph" and references node ids.
|
|
std::ifstream in(dotPath);
|
|
std::string contents((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
EXPECT_NE(contents.find("digraph"), std::string::npos) << "missing digraph header";
|
|
EXPECT_NE(contents.find("1"), std::string::npos);
|
|
EXPECT_NE(contents.find("4"), std::string::npos);
|
|
UFile::erase(dotPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// exportPoses
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, ExportPosesRawFormatRoundTripsViaImport)
|
|
{
|
|
// Build a 4-node chain and export poses in the raw (format 0) layout, then
|
|
// re-read the file via graph::importPoses() and confirm we get the same
|
|
// poses back. This covers the local/optimized export path.
|
|
for(int i = 0; i < 4; ++i) process();
|
|
|
|
const std::string outPath = test::tempPath(uFormat("rtabmap_test_poses_raw_%d.txt", test::getPid()));
|
|
UFile::erase(outPath.c_str());
|
|
rtabmap_->exportPoses(outPath, /*optimized=*/true, /*global=*/false, /*format=*/0);
|
|
ASSERT_TRUE(UFile::exists(outPath));
|
|
|
|
std::map<int, Transform> imported;
|
|
std::map<int, double> stamps;
|
|
std::multimap<int, Link> links;
|
|
ASSERT_TRUE(graph::importPoses(outPath, /*format=*/0, imported, &links, &stamps));
|
|
EXPECT_EQ(imported.size(), 4u);
|
|
for(const auto & kv : imported)
|
|
{
|
|
const Transform expected(float(kv.first), 0.0f, 0.0f, 0, 0, 0);
|
|
EXPECT_NEAR(kv.second.x(), expected.x(), 1e-3f);
|
|
EXPECT_NEAR(kv.second.y(), expected.y(), 1e-3f);
|
|
EXPECT_NEAR(kv.second.z(), expected.z(), 1e-3f);
|
|
}
|
|
UFile::erase(outPath.c_str());
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ExportPosesKittiFormatWritesOneMatrixPerLine)
|
|
{
|
|
for(int i = 0; i < 3; ++i) process();
|
|
const std::string outPath = test::tempPath(uFormat("rtabmap_test_poses_kitti_%d.txt", test::getPid()));
|
|
UFile::erase(outPath.c_str());
|
|
rtabmap_->exportPoses(outPath, /*optimized=*/true, /*global=*/false, /*format=*/2);
|
|
ASSERT_TRUE(UFile::exists(outPath));
|
|
// KITTI: 3x4 = 12 floats per line, 3 lines for 3 nodes.
|
|
std::ifstream in(outPath);
|
|
int lineCount = 0;
|
|
std::string line;
|
|
while(std::getline(in, line))
|
|
{
|
|
++lineCount;
|
|
std::istringstream is(line);
|
|
int floats = 0;
|
|
float v;
|
|
while(is >> v) ++floats;
|
|
EXPECT_EQ(floats, 12) << "line " << lineCount;
|
|
}
|
|
EXPECT_EQ(lineCount, 3);
|
|
UFile::erase(outPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// globalBundleAdjustment on a real graph
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, GlobalBundleAdjustmentOnPopulatedGraphPreservesPosesIfOptimizationFails)
|
|
{
|
|
if(!Optimizer::isAvailable(Optimizer::kTypeG2O))
|
|
{
|
|
GTEST_SKIP() << "g2o optimizer not available in this build (required by BA)";
|
|
}
|
|
// BA on a non-empty graph runs the optimizer end-to-end. Synthetic one-hot
|
|
// features at fixed 2D positions are geometrically degenerate (same 2D
|
|
// projection across all 3 frames despite different poses), so g2o's BA
|
|
// converges to NaN and the call returns false. The important invariant
|
|
// is the failure path: when BA reports failure, the original optimized
|
|
// poses MUST be left untouched (the code only assigns _optimizedPoses
|
|
// when the optimizer returned a non-empty result).
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kRtabmapLoopThr()] = "1"; // suppress loop closures (posterior never exceeds 1)
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(i, kMatchSlot),
|
|
Transform(float(i - 1), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
ASSERT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
|
|
const auto before = rtabmap.getLocalOptimizedPoses();
|
|
ASSERT_EQ(before.size(), 3u);
|
|
|
|
// Whether BA succeeds or fails on this degenerate input, the post-state
|
|
// must remain coherent (no NaN poses, same node ids).
|
|
rtabmap.globalBundleAdjustment(Optimizer::kTypeG2O, /*rematchFeatures=*/true);
|
|
const auto after = rtabmap.getLocalOptimizedPoses();
|
|
ASSERT_EQ(after.size(), 3u);
|
|
for(const auto & kv : after)
|
|
{
|
|
ASSERT_TRUE(before.count(kv.first));
|
|
EXPECT_FALSE(kv.second.isNull());
|
|
EXPECT_TRUE(std::isfinite(kv.second.x()));
|
|
EXPECT_TRUE(std::isfinite(kv.second.y()));
|
|
EXPECT_TRUE(std::isfinite(kv.second.z()));
|
|
}
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
namespace ba_synth {
|
|
// Synthetic SfM scene helpers used by GlobalBundleAdjustmentRefinesPosesOnSynthScene.
|
|
//
|
|
// Builds a small "structure-from-motion" world:
|
|
// - N random 3D landmarks in a cube around the origin
|
|
// - K camera frames spaced around the volume looking inward
|
|
// - Each frame's SensorData carries the visible subset of landmarks, with
|
|
// unique one-hot descriptors so cross-frame matching is trivial and 2D
|
|
// keypoints obtained by reprojecting the landmark through the camera model.
|
|
|
|
struct Landmark3D {
|
|
cv::Point3f world; // in world frame
|
|
int slot; // unique slot id -> one-hot descriptor row
|
|
};
|
|
|
|
inline CameraModel sceneCamera()
|
|
{
|
|
// 640x480 with fx=fy=500: same FOV as the 64x64/f=50 cameras used elsewhere
|
|
// in this file, but 10x angular resolution per pixel - so the same 0.1 px
|
|
// detector noise represents 10x sharper observations in BA's cost function.
|
|
return CameraModel(500.0, 500.0, 320.0, 240.0, CameraModel::opticalRotation(), 0.0, cv::Size(640, 480));
|
|
}
|
|
|
|
inline std::vector<Landmark3D> randomLandmarks(int n, float halfExtent, unsigned seed)
|
|
{
|
|
std::mt19937 rng(seed);
|
|
std::uniform_real_distribution<float> dist(-halfExtent, halfExtent);
|
|
std::vector<Landmark3D> pts;
|
|
pts.reserve(n);
|
|
for(int i = 0; i < n; ++i)
|
|
{
|
|
pts.push_back({cv::Point3f(dist(rng), dist(rng), dist(rng)), /*slot=*/i});
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
// 3D point in robot base frame, given the camera optical-frame coords. Inverse
|
|
// of the (z_optical = x_base; y_base = -x_optical; z_base = -y_optical) mapping
|
|
// used implicitly by makeFeaturesData. Provided so tests can verify projection.
|
|
inline cv::Point3f opticalToBase(const cv::Point3f & optical)
|
|
{
|
|
return cv::Point3f(optical.z, -optical.x, -optical.y);
|
|
}
|
|
|
|
inline cv::Point3f baseToOptical(const cv::Point3f & base)
|
|
{
|
|
return cv::Point3f(-base.y, -base.z, base.x);
|
|
}
|
|
|
|
// Camera positions on a horizontal circle of radius @p radius around the
|
|
// origin, each looking inward (yaw = angle + pi).
|
|
inline std::vector<Transform> hexagonalPoses(int count, float radius)
|
|
{
|
|
std::vector<Transform> out;
|
|
out.reserve(count);
|
|
for(int i = 0; i < count; ++i)
|
|
{
|
|
const float angle = 2.0f * float(M_PI) * float(i) / float(count);
|
|
const float x = radius * std::cos(angle);
|
|
const float y = radius * std::sin(angle);
|
|
const float yaw = angle + float(M_PI); // robot x points toward origin
|
|
out.emplace_back(x, y, 0.0f, 0.0f, 0.0f, yaw);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Builds a SensorData for the frame at @p basePose, containing the visible
|
|
// subset of @p landmarks. Mirrors a stereo/RGB-D sensor: the 2D keypoint is
|
|
// disturbed by independent sub-pixel detector noise (@p pixelNoiseStd, per
|
|
// axis, in pixels) and the triangulated 3D point is disturbed by depth
|
|
// estimation noise (@p depthNoiseStd, per axis, in metres, in the base frame).
|
|
// The two noise channels are uncorrelated, as they are in a real sensor.
|
|
inline SensorData makeBaSceneFrame(
|
|
int id,
|
|
const Transform & basePose,
|
|
const std::vector<Landmark3D> & landmarks,
|
|
const CameraModel & camera,
|
|
float pixelNoiseStd,
|
|
float depthNoiseStd,
|
|
std::mt19937 & rng)
|
|
{
|
|
const int kSlots = 32; // matches makeFeaturesData layout
|
|
const Transform world_to_base = basePose.inverse();
|
|
const cv::Size imgSize = camera.imageSize();
|
|
cv::Mat image(imgSize.height, imgSize.width, CV_8UC1, cv::Scalar(128));
|
|
SensorData data(image, camera); data.setId(id);
|
|
|
|
std::vector<cv::KeyPoint> kpts;
|
|
std::vector<cv::Point3f> pts3;
|
|
std::vector<cv::Mat> descRows;
|
|
std::normal_distribution<float> pxNoise(0.0f, pixelNoiseStd);
|
|
std::normal_distribution<float> dpNoise(0.0f, depthNoiseStd);
|
|
const int kDescCols = kSlots * 8;
|
|
for(const auto & lm : landmarks)
|
|
{
|
|
// World point -> base frame (ground truth).
|
|
const cv::Point3f & w = lm.world;
|
|
const Transform pBase4 = world_to_base * Transform(w.x, w.y, w.z, 0, 0, 0);
|
|
const cv::Point3f pBaseGt(pBase4.x(), pBase4.y(), pBase4.z());
|
|
// Base -> optical (z forward) for visibility / projection.
|
|
const cv::Point3f pOpt = baseToOptical(pBaseGt);
|
|
if(pOpt.z <= 0.05f) continue; // behind camera or too close
|
|
|
|
float u, v;
|
|
camera.reproject(pOpt.x, pOpt.y, pOpt.z, u, v);
|
|
if(pixelNoiseStd > 0.0f)
|
|
{
|
|
u += pxNoise(rng);
|
|
v += pxNoise(rng);
|
|
}
|
|
if(u < 1.0f || u >= float(imgSize.width) - 1.0f ||
|
|
v < 1.0f || v >= float(imgSize.height) - 1.0f)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 3D noise (in base frame) applied independently of the 2D noise.
|
|
cv::Point3f pBase = pBaseGt;
|
|
if(depthNoiseStd > 0.0f)
|
|
{
|
|
pBase.x += dpNoise(rng);
|
|
pBase.y += dpNoise(rng);
|
|
pBase.z += dpNoise(rng);
|
|
}
|
|
|
|
// One-hot descriptor for this landmark: a single 1000 at column
|
|
// (lm.slot * 8), so every frame that observes this landmark emits the
|
|
// SAME descriptor row and the dictionary maps them to one shared word.
|
|
UASSERT(lm.slot >= 0 && lm.slot < kSlots);
|
|
cv::Mat row = cv::Mat::zeros(1, kDescCols, CV_32F);
|
|
row.at<float>(0, lm.slot * 8) = 1000.0f;
|
|
|
|
kpts.emplace_back(u, v, 1.0f);
|
|
pts3.emplace_back(pBase);
|
|
descRows.push_back(row);
|
|
}
|
|
|
|
cv::Mat desc;
|
|
if(!descRows.empty())
|
|
{
|
|
cv::vconcat(descRows, desc);
|
|
}
|
|
data.setFeatures(kpts, pts3, desc);
|
|
return data;
|
|
}
|
|
|
|
} // namespace ba_synth
|
|
|
|
TEST(RtabmapTest, GlobalBundleAdjustmentRefinesPosesOnSynthScene)
|
|
{
|
|
if(!Optimizer::isAvailable(Optimizer::kTypeG2O))
|
|
{
|
|
GTEST_SKIP() << "g2o optimizer not available in this build (required by BA)";
|
|
}
|
|
|
|
using namespace ba_synth;
|
|
// 12 random world landmarks inside a 4 m cube; 6 cameras around them at
|
|
// 4 m radius. Each camera observes the visible subset, with independent
|
|
// sub-pixel noise on the 2D keypoints AND per-axis noise on the 3D point
|
|
// stored in the base frame (mirroring detector + depth-estimate noise on
|
|
// a stereo / RGB-D sensor). BA's task is to recover poses consistent with
|
|
// the noisy observations; the optimum is the GT poses we fed in.
|
|
const auto landmarks = randomLandmarks(/*n=*/12, /*halfExtent=*/2.0f, /*seed=*/42);
|
|
const CameraModel camera = sceneCamera();
|
|
const auto gtPoses = hexagonalPoses(/*count=*/6, /*radius=*/4.0f);
|
|
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kMemSTMSize()] = "10"; // keep all frames in STM
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
params[Parameters::kKpMaxFeatures()] = "20"; // up to 12 landmarks/frame
|
|
params[Parameters::kRtabmapLoopThr()] = "1"; // suppress Bayes loops
|
|
// Anchor BA on the FIRST (clean) pose. With kRGBDOptimizeFromGraphEnd
|
|
// disabled (the default), BA's root would be the last node, which is
|
|
// noisy here -> entire BA solution would inherit that node's drift.
|
|
params[Parameters::kRGBDOptimizeFromGraphEnd()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
std::mt19937 rng(/*seed=*/123);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const float pixelNoise = 0.1f; // sub-pixel detector noise
|
|
const float depthNoise = 0.02f; // 2 cm std on each 3D feature axis
|
|
const float poseTransNoiseStd = 0.05f; // 5 cm std per axis on input odom poses
|
|
const float poseRotNoiseStd = float(2.0 * M_PI / 180.0); // 2 deg std per axis
|
|
std::normal_distribution<float> transNoise(0.0f, poseTransNoiseStd);
|
|
std::normal_distribution<float> rotNoise(0.0f, poseRotNoiseStd);
|
|
std::vector<Transform> noisyPoses;
|
|
noisyPoses.reserve(gtPoses.size());
|
|
// BA anchors on the first pose - keep it clean so the absolute reference
|
|
// stays at GT and the rest can be driven toward GT by reprojection.
|
|
noisyPoses.push_back(gtPoses[0]);
|
|
for(size_t i = 1; i < gtPoses.size(); ++i)
|
|
{
|
|
const auto & gt = gtPoses[i];
|
|
float x, y, z, roll, pitch, yaw;
|
|
gt.getTranslationAndEulerAngles(x, y, z, roll, pitch, yaw);
|
|
noisyPoses.emplace_back(
|
|
x + transNoise(rng), y + transNoise(rng), z + transNoise(rng),
|
|
roll + rotNoise(rng), pitch + rotNoise(rng), yaw + rotNoise(rng));
|
|
}
|
|
for(size_t i = 0; i < gtPoses.size(); ++i)
|
|
{
|
|
// Observations projected from the GT pose; odom passed to Rtabmap is
|
|
// the noisy version so the local graph starts off-GT.
|
|
SensorData data = makeBaSceneFrame(int(i + 1), gtPoses[i], landmarks, camera, pixelNoise, depthNoise, rng);
|
|
ASSERT_GE(data.keypoints().size(), 6u) << "frame " << (i + 1) << " not enough visible landmarks";
|
|
ASSERT_TRUE(rtabmap.process(data, noisyPoses[i], cov));
|
|
}
|
|
|
|
// Manually add cross-cluster global-closure links (using the noisy odom
|
|
// deltas as the relative transform - same source as the chain neighbors)
|
|
// so BA sees a richly connected graph and can triangulate landmarks
|
|
// against every camera pair, not only consecutive frames.
|
|
for(size_t i = 0; i < gtPoses.size(); ++i)
|
|
{
|
|
for(size_t j = i + 2; j < gtPoses.size(); ++j)
|
|
{
|
|
const Transform relPose = noisyPoses[i].inverse() * noisyPoses[j];
|
|
const Link link(int(i + 1), int(j + 1), Link::kGlobalClosure, relPose,
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap.addLink(link)) << "link " << (i + 1) << "->" << (j + 1);
|
|
}
|
|
}
|
|
|
|
const auto before = rtabmap.getLocalOptimizedPoses();
|
|
ASSERT_EQ(before.size(), gtPoses.size());
|
|
|
|
const bool baOk = rtabmap.globalBundleAdjustment(Optimizer::kTypeG2O, /*rematchFeatures=*/false);
|
|
EXPECT_TRUE(baOk) << "BA must converge on the synthetic scene";
|
|
|
|
const auto after = rtabmap.getLocalOptimizedPoses();
|
|
ASSERT_EQ(after.size(), gtPoses.size());
|
|
|
|
// Every refined pose stays finite.
|
|
for(const auto & kv : after)
|
|
{
|
|
EXPECT_FALSE(kv.second.isNull());
|
|
EXPECT_TRUE(std::isfinite(kv.second.x()));
|
|
EXPECT_TRUE(std::isfinite(kv.second.y()));
|
|
EXPECT_TRUE(std::isfinite(kv.second.z()));
|
|
}
|
|
// With clean GT odom seeded and pixel + depth measurement noise on the
|
|
// features, BA's optimum is GT; residual drift is dictated by the noise
|
|
// magnitude. Expect well under 5 cm mean pose drift.
|
|
// Build a GT map keyed by node id for graph::calcRMSE().
|
|
std::map<int, Transform> gtMap;
|
|
for(size_t i = 0; i < gtPoses.size(); ++i)
|
|
{
|
|
gtMap.emplace(int(i + 1), gtPoses[i]);
|
|
}
|
|
auto rmse = [&](const std::map<int, Transform> & poses) {
|
|
float t_rmse, t_mean, t_med, t_std, t_min, t_max;
|
|
float r_rmse, r_mean, r_med, r_std, r_min, r_max;
|
|
graph::calcRMSE(gtMap, poses,
|
|
t_rmse, t_mean, t_med, t_std, t_min, t_max,
|
|
r_rmse, r_mean, r_med, r_std, r_min, r_max,
|
|
/*align2D=*/false);
|
|
return std::make_pair(t_rmse, r_rmse);
|
|
};
|
|
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";
|
|
EXPECT_LT(rRmseAfter, rRmseBefore) << "BA must reduce rotational RMSE";
|
|
// Bounds set with ~2x headroom over the worst RMSE observed across 5 RNG
|
|
// seeds (translation ~36 mm, rotation ~0.45 deg at 640x480).
|
|
EXPECT_LT(tRmseAfter, 0.06f) << "post-BA translational RMSE too large";
|
|
EXPECT_LT(rRmseAfter, 1.0f) << "post-BA rotational RMSE (deg) too large";
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST_F(RtabmapFixture, ExportPosesTumFormatIncludesStampPerLine)
|
|
{
|
|
for(int i = 0; i < 3; ++i) process();
|
|
const std::string outPath = test::tempPath(uFormat("rtabmap_test_poses_tum_%d.txt", test::getPid()));
|
|
UFile::erase(outPath.c_str());
|
|
// Format 1 = RGBD-SLAM / TUM: stamp x y z qw qx qy qz (8 fields).
|
|
rtabmap_->exportPoses(outPath, /*optimized=*/true, /*global=*/false, /*format=*/1);
|
|
ASSERT_TRUE(UFile::exists(outPath));
|
|
std::ifstream in(outPath);
|
|
int lineCount = 0;
|
|
std::string line;
|
|
while(std::getline(in, line))
|
|
{
|
|
if(line.empty() || line[0] == '#') continue;
|
|
++lineCount;
|
|
std::istringstream is(line);
|
|
int fields = 0;
|
|
double v;
|
|
while(is >> v) ++fields;
|
|
EXPECT_EQ(fields, 8) << "line " << lineCount;
|
|
}
|
|
EXPECT_EQ(lineCount, 3);
|
|
UFile::erase(outPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rehearsal merge: kMemRehearsalSimilarity < 1.0 collapses near-duplicate
|
|
// consecutive STM frames into a single node.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, RehearsalMergesConsecutiveLookalikeFramesInSTM)
|
|
{
|
|
// When the new STM signature is similar enough to the previous one,
|
|
// Memory::rehearsalMerge() folds it into the prior node and no new
|
|
// location is created. With matching kMatchSlot features and a low
|
|
// similarity threshold, processing N2 right after N1 should leave the
|
|
// map with a single location.
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kMemRehearsalSimilarity()] = "0.2"; // enable rehearsal
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, kMatchSlot), Transform(0, 0, 0, 0, 0, 0), cov));
|
|
const int firstId = rtabmap.getLastLocationId();
|
|
const int wmBefore = rtabmap.getWMSize();
|
|
|
|
// Process N2 with identical features at the same pose -> rehearsal merge.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, kMatchSlot), Transform(0, 0, 0, 0, 0, 0), cov));
|
|
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itMerged = stats.find(Statistics::kMemoryRehearsal_merged());
|
|
ASSERT_NE(itMerged, stats.end());
|
|
EXPECT_EQ(int(itMerged->second), firstId) << "rehearsal should have merged N2 into N1";
|
|
|
|
// WM size must not have grown - the merged node is the same id.
|
|
EXPECT_EQ(rtabmap.getWMSize(), wmBefore);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// detectMoreLoopClosures: post-hoc clustering loops nodes that revisit a
|
|
// previous spatial neighborhood but did NOT trigger an online loop closure.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, DetectMoreLoopClosuresAddsLinksBetweenSpatialRevisits)
|
|
{
|
|
// Build a 5-node chain where N5 revisits N1's pose with matching features.
|
|
// Online loop closure is suppressed (kRtabmapLoopThr=1, posterior never exceeds 1) so the only
|
|
// inter-node links are odom neighbors. detectMoreLoopClosures should then
|
|
// cluster (N1,N5) under the spatial radius and add a global loop closure
|
|
// link after running visual registration on them.
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kRtabmapLoopThr()] = "1"; // suppress online loop detection
|
|
params[Parameters::kMemBinDataKept()] = "true"; // keep feature data for post-hoc registration
|
|
// STMSize: small enough that the (N1,N5) id-diff (=4) passes the
|
|
// "too close nodes (id-diff < STMSize)" filter inside detectMoreLoopClosures,
|
|
// but >= 2 so all 5 nodes stay in the local optimized graph.
|
|
params[Parameters::kMemSTMSize()] = "2";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// First odom pose is a tiny offset from origin: Transform(0,0,0,...) is
|
|
// interpreted by Rtabmap as an "odometry reset" and triggers a new map id
|
|
// (severing the odom chain). We avoid that for both N1 and N5.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/2), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(3, /*featSlot=*/3), Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/4), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
// N5 revisits N1's pose with the SAME kMatchSlot features.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(5, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
// Online loop closure must NOT have fired (suppressed by the high threshold).
|
|
ASSERT_EQ(rtabmap.getLoopClosureId(), 0);
|
|
|
|
// Cluster radius 0.5 m around each node -> (N1, N5) get paired.
|
|
const int added = rtabmap.detectMoreLoopClosures(
|
|
/*clusterRadiusMax=*/0.5f,
|
|
/*clusterAngle=*/M_PI/6.0f,
|
|
/*iterations=*/1,
|
|
/*intraSession=*/true,
|
|
/*interSession=*/false);
|
|
EXPECT_GE(added, 1) << "expected at least one post-hoc loop closure between N1 and N5";
|
|
|
|
// A non-neighbor link between N1 and N5 must now exist in Memory (stored
|
|
// in either direction depending on the registration outcome).
|
|
const Signature * s5 = rtabmap.getMemory()->getSignature(5);
|
|
const Signature * s1 = rtabmap.getMemory()->getSignature(1);
|
|
ASSERT_NE(s5, nullptr);
|
|
ASSERT_NE(s1, nullptr);
|
|
auto hasClosureTo = [](const Signature * s, int target) {
|
|
for(const auto & kv : s->getLinks())
|
|
{
|
|
if(kv.first == target && kv.second.type() != Link::kNeighbor)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
EXPECT_TRUE(hasClosureTo(s5, 1) || hasClosureTo(s1, 5)) << "expected a non-neighbor link between N1 and N5";
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// refineLinks: re-run registration on every existing link, update those that
|
|
// converge.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, RefineLinksUpdatesConvergingNeighborLinks)
|
|
{
|
|
// Process 3 frames whose features are identical (full kMatchSlot match
|
|
// at the same 2D positions). Visual registration between every pair
|
|
// returns identity, while the odom links carry +1 m translation each.
|
|
// refineLinks should therefore mutate every neighbor link in the graph
|
|
// (returning >= 1).
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kRtabmapLoopThr()] = "1";
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
params[Parameters::kMemSTMSize()] = "5";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
// Small odom deltas so that registration's initial guess does not project
|
|
// the (x=1 m) 3D points outside the 64x64 image (which would otherwise
|
|
// abort the visual transformation step before refining).
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(i, kMatchSlot),
|
|
Transform(float(i) * 0.01f, 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
const int refined = rtabmap.refineLinks();
|
|
EXPECT_GE(refined, 1) << "refineLinks must update at least one converging link";
|
|
|
|
// Spot-check: the (N2,N1) odom link's transform should now reflect the
|
|
// visual registration outcome (~identity) rather than the original 1 m
|
|
// odom delta.
|
|
const Signature * s2 = rtabmap.getMemory()->getSignature(2);
|
|
ASSERT_NE(s2, nullptr);
|
|
auto it = s2->getLinks().find(1);
|
|
ASSERT_NE(it, s2->getLinks().end());
|
|
// Visual registration on identical features returns ~identity, replacing
|
|
// the original ~0.01 m odom delta with ~0.
|
|
EXPECT_LT(std::abs(it->second.transform().x()), 0.005f) << "refined transform should be visual (~identity)";
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Proximity detection (kRGBDProximityBySpace): the "second loop-closure path"
|
|
// inside Rtabmap::process() that fires when a new node sits close in space
|
|
// (per optimized poses) to a previous WM node, with sufficient appearance
|
|
// overlap to register against it. Adds a kLocalSpaceClosure link.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, ProximityBySpaceAddsLocalSpaceClosureOnRevisit)
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDProximityBySpace()] = "true"; // enable proximity
|
|
params[Parameters::kRtabmapLoopThr()] = "1"; // suppress Bayes loop
|
|
params[Parameters::kMemSTMSize()] = "2"; // span (N1,N5) id-diff filter
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// Walk forward, then back to the start. N1 and N5 share full kMatchSlot
|
|
// features and are essentially co-located in odometry -- proximity detection
|
|
// during N5's process() should pair them.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/2), Transform(0.1f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(3, /*featSlot=*/3), Transform(0.2f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/4), Transform(0.1f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(5, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// A proximity detection on N5 should have created a (N5,N1) link.
|
|
const Signature * s5 = rtabmap.getMemory()->getSignature(5);
|
|
ASSERT_NE(s5, nullptr);
|
|
bool foundProximityLink = false;
|
|
for(const auto & kv : s5->getLinks())
|
|
{
|
|
if(kv.first == 1 &&
|
|
(kv.second.type() == Link::kLocalSpaceClosure ||
|
|
kv.second.type() == Link::kGlobalClosure))
|
|
{
|
|
foundProximityLink = true;
|
|
break;
|
|
}
|
|
}
|
|
EXPECT_TRUE(foundProximityLink) << "kRGBDProximityBySpace must produce a (N5,N1) closure link";
|
|
// Statistics confirm the proximity path ran and added at least one link.
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itProx = stats.find(Statistics::kProximitySpace_detections_added_visually());
|
|
ASSERT_NE(itProx, stats.end());
|
|
EXPECT_GE(itProx->second, 1.0f);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, ProximityByTimeDetectsRevisitInSTM)
|
|
{
|
|
// kRGBDProximityByTime fires when a new node sits close in TIME (i.e.,
|
|
// shares an STM window with a previous node) and registers against it.
|
|
// Set up two consecutive frames with overlapping kMatchSlot features so
|
|
// the new one proximity-detects the previous STM frame.
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDProximityBySpace()] = "false";
|
|
params[Parameters::kRGBDProximityByTime()] = "true";
|
|
params[Parameters::kRtabmapLoopThr()] = "1";
|
|
params[Parameters::kMemSTMSize()] = "5"; // keep all in STM
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/2), Transform(0.05f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(3, kMatchSlot), Transform(0.01f, 0, 0, 0, 0, 0), cov));
|
|
|
|
// proximity-by-time should have added a non-neighbor link to N1 on N3.
|
|
const Signature * s3 = rtabmap.getMemory()->getSignature(3);
|
|
ASSERT_NE(s3, nullptr);
|
|
bool found = false;
|
|
for(const auto & kv : s3->getLinks())
|
|
{
|
|
if(kv.first == 1 && kv.second.type() != Link::kNeighbor)
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
EXPECT_TRUE(found) << "kRGBDProximityByTime must produce a (N3,N1) closure link";
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// Note: the repairGraph()-abort branch (triggered when removing the max-error
|
|
// link would disconnect more than one pose from the local graph - see
|
|
// Rtabmap.cpp:~5566) is not exercised here. The natural Rtabmap process flow
|
|
// keeps the graph connected through odom neighbors, so reaching that branch
|
|
// requires either two disconnected map sessions bridged by a single bad loop
|
|
// (built across triggerNewMap) or a hand-crafted Memory state - both fragile
|
|
// and out of scope for the orchestration coverage in this file.
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Landmarks / markers: SensorData::setLandmarks() attached to processed frames
|
|
// produces kLandmark links into Memory and shows up in getGraph as negative-id
|
|
// poses sharing the constraint between every observer.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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;
|
|
const cv::Mat lmCov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kLm = 42;
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
|
|
// Frame N1 at (0,0,0) sees landmark 1 m forward.
|
|
{
|
|
SensorData d(image); d.setId(1);
|
|
Landmarks lms;
|
|
lms.emplace(kLm, Landmark(kLm, /*size=*/0.1f, Transform(1.0f, 0, 0, 0, 0, 0), lmCov));
|
|
d.setLandmarks(lms);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
}
|
|
// Frame N2 at (1,0,0) sees the same landmark right next to it (0 m forward).
|
|
{
|
|
SensorData d(image); d.setId(2);
|
|
Landmarks lms;
|
|
lms.emplace(kLm, Landmark(kLm, /*size=*/0.1f, Transform(0.0f, 0, 0, 0, 0, 0), lmCov));
|
|
d.setLandmarks(lms);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
// Both signatures carry a kLandmark link to -kLm.
|
|
for(int nodeId : {1, 2})
|
|
{
|
|
const Signature * s = rtabmap.getMemory()->getSignature(nodeId);
|
|
ASSERT_NE(s, nullptr) << "node " << nodeId;
|
|
bool foundLmLink = false;
|
|
for(const auto & kv : s->getLandmarks())
|
|
{
|
|
if(kv.first == -kLm && kv.second.type() == Link::kLandmark)
|
|
{
|
|
foundLmLink = true;
|
|
break;
|
|
}
|
|
}
|
|
EXPECT_TRUE(foundLmLink) << "node " << nodeId << " missing kLandmark link to -" << kLm;
|
|
}
|
|
|
|
// getGraph (global) exposes the landmark pose under its negative id; both
|
|
// observers should map back to the same world location (~ (1,0,0)).
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> links;
|
|
rtabmap.getGraph(poses, links, /*optimized=*/true, /*global=*/true);
|
|
auto itLm = poses.find(-kLm);
|
|
ASSERT_NE(itLm, poses.end()) << "expected landmark pose at key -" << kLm;
|
|
EXPECT_NEAR(itLm->second.x(), 1.0f, 0.1f);
|
|
EXPECT_NEAR(itLm->second.y(), 0.0f, 0.1f);
|
|
EXPECT_NEAR(itLm->second.z(), 0.0f, 0.1f);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
TEST(RtabmapTest, DetectMoreLoopClosuresReturnsMinusOneWhenIntraAndInterBothFalse)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(badLoopClosureParams());
|
|
// Intra and inter both false -> illegal call.
|
|
EXPECT_EQ(rtabmap.detectMoreLoopClosures(0.5f, M_PI/6.0f, 1, false, false), -1);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-session DB reload: close, reopen with kMemIncrementalMemory=true,
|
|
// continue mapping, and verify session bridging (new map id, node ids continue
|
|
// from where the previous session left off, prior nodes still queryable).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, MultiSessionReloadContinuesMappingWithNewMapId)
|
|
{
|
|
const std::string dbPath = uniqueDbPath();
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
int session1LastId = 0;
|
|
int session1MapId = -1;
|
|
{
|
|
// Session 1: build a short chain.
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams(), dbPath);
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
session1LastId = rtabmap.getLastLocationId();
|
|
session1MapId = rtabmap.getMemory()->getMapId(session1LastId);
|
|
ASSERT_EQ(session1LastId, 3);
|
|
rtabmap.close(true); // save
|
|
}
|
|
|
|
{
|
|
// Session 2: reopen the DB in incremental mode and add 2 more nodes.
|
|
Rtabmap rtabmap;
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDStartAtOrigin()] = "true"; // start session 2 clean
|
|
rtabmap.init(params, dbPath);
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
for(int i = 1; i <= 2; ++i)
|
|
{
|
|
SensorData d(image); d.setId(0); // auto-id
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
const int session2LastId = rtabmap.getLastLocationId();
|
|
const int session2MapId = rtabmap.getMemory()->getMapId(session2LastId);
|
|
|
|
// Session 2's node ids continue past session 1's max id.
|
|
EXPECT_GT(session2LastId, session1LastId);
|
|
// Different map id since this is a new mapping session.
|
|
EXPECT_NE(session2MapId, session1MapId);
|
|
|
|
// Session 1's last node is still reachable via Memory (may live in LTM).
|
|
EXPECT_NE(rtabmap.getMemory()->getMapId(session1LastId, /*lookInDatabase=*/true), -1);
|
|
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
TEST(RtabmapTest, RehearsalDoesNotMergeDissimilarConsecutiveFrames)
|
|
{
|
|
// Sanity check: two frames with disjoint feature slots (similarity ~0)
|
|
// must NOT trigger a rehearsal merge even at a low threshold.
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kMemRehearsalSimilarity()] = "0.2";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0, 0, 0, 0, 0, 0), cov));
|
|
const int wmBefore = rtabmap.getWMSize();
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(0.1f, 0, 0, 0, 0, 0), cov));
|
|
|
|
const auto & stats = rtabmap.getStatistics().data();
|
|
auto itMerged = stats.find(Statistics::kMemoryRehearsal_merged());
|
|
ASSERT_NE(itMerged, stats.end());
|
|
EXPECT_EQ(int(itMerged->second), 0) << "no merge expected for disjoint features";
|
|
EXPECT_EQ(rtabmap.getWMSize(), wmBefore + 1);
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GPS-based loop closure filter (kRtabmapLoopGPS): nodes carrying GPS data
|
|
// participate in Bayes scoring only when their GPS position is within
|
|
// kRGBDLocalRadius of the query frame's GPS. The GPS data must round-trip
|
|
// through Memory and the Bayes likelihood call must complete without crashes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, ProcessAcceptsGPSAndStoresItOnSignature)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRtabmapLoopGPS()] = "true";
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
// Build 3 frames, each carrying a GPS fix near the same area (slightly
|
|
// shifted in longitude). With kRtabmapLoopGPS=true the GPS-distance gate
|
|
// participates in Bayes candidate filtering; we just verify the data
|
|
// round-trips and processing succeeds.
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i);
|
|
// Matches the (latitude, longitude) constants used by test_gps.cpp.
|
|
const GPS fix(/*stamp=*/double(i), /*lon=*/-71.94304 + 1e-6 * double(i),
|
|
/*lat=*/45.37855, /*alt=*/30.0, /*err=*/2.0, /*bearing=*/0.0);
|
|
d.setGPS(fix);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
// GPS data must be preserved on every node Memory still holds.
|
|
for(int id : {1, 2, 3})
|
|
{
|
|
const Signature * s = rtabmap.getMemory()->getSignature(id);
|
|
ASSERT_NE(s, nullptr) << "node " << id;
|
|
EXPECT_GT(s->sensorData().gps().stamp(), 0.0) << "node " << id << " lost its GPS fix";
|
|
}
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memory threshold triggering LTM transfer: when kMemMaxStMemorySize is small
|
|
// and we process more frames than it allows, older nodes get pushed out of
|
|
// STM into WM, and eventually the working-memory budget enforced by
|
|
// kRtabmapMemoryThr causes `Memory::forget()` to spill them to LTM.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, MemoryThresholdTransfersOlderNodesOutOfWM)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemSTMSize()] = "2";
|
|
params[Parameters::kRtabmapMemoryThr()] = "3"; // cap WM at 3 nodes
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
// Process more frames than the threshold; the oldest must be evicted.
|
|
for(int i = 1; i <= 8; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
// The WM size stays bounded by the memory threshold (plus STM).
|
|
EXPECT_LE(rtabmap.getWMSize(), 3);
|
|
// Total ids handled remains visible via getLastLocationId even though most
|
|
// were transferred out of WM.
|
|
EXPECT_EQ(rtabmap.getLastLocationId(), 8);
|
|
// Oldest node (N1) must no longer live in STM/WM but should still be
|
|
// reachable via the database lookup.
|
|
EXPECT_EQ(rtabmap.getMemory()->getSignature(1), nullptr) << "N1 should be in LTM";
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// kRtabmapStartNewMapOnLoopClosure: when a loop closure is detected between
|
|
// the current session and a PRIOR session (loaded from the DB), the option
|
|
// forces a new map id rather than collapsing the two visits into the same
|
|
// session. Intra-session loops are unaffected.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, StartNewMapOnLoopClosureTriggersOnCrossSessionLoop)
|
|
{
|
|
const std::string dbPath = uniqueDbPath();
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// Session 1: build the loop-target scaffolding (N4 = full kMatchSlot,
|
|
// flanked by partials N3 / N5 - the same setup as the existing
|
|
// ProcessRejectsBadLoopClosureWhenMaxErrorExceeded test).
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(3, {kMatchSlot, kMatchSlot, 23, 23, 23, 23, 23, 23}),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(5, {kMatchSlot, kMatchSlot, 25, 25, 25, 25, 25, 25}),
|
|
Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
rtabmap.close(true); // save
|
|
}
|
|
|
|
// Session 2: incremental mode (new map id), kRtabmapStartNewMapOnLoopClosure
|
|
// enabled. The first cross-session loop must produce a new map id for the
|
|
// frame that follows it.
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
params[Parameters::kRtabmapStartNewMapOnLoopClosure()] = "true";
|
|
params[Parameters::kRGBDStartAtOrigin()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
|
|
const int session1MapId = rtabmap.getMemory()->getMapId(4); // map id of N4 in DB
|
|
// With kRtabmapStartNewMapOnLoopClosure=true, session-2 frames are
|
|
// DROPPED until a loop closure with the prior session fires. Try a
|
|
// couple of non-matching frames first - they get deleted.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(0, /*featSlot=*/6), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(0, /*featSlot=*/7), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
// kMatchSlot frame triggers the Bayes loop with N4 from session 1; the
|
|
// new session opens at this point with a fresh map id.
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(0, /*featSlot=*/kMatchSlot), Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_EQ(rtabmap.getLoopClosureId(), 4) << "expected cross-session loop to N4";
|
|
const int mapAtLoop = rtabmap.getMemory()->getMapId(rtabmap.getLastLocationId());
|
|
EXPECT_NE(mapAtLoop, session1MapId)
|
|
<< "loop-anchored frame must inherit a NEW map id (session1=" << session1MapId
|
|
<< " atLoop=" << mapAtLoop << ")";
|
|
rtabmap.close(false);
|
|
}
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process with NaN / null odometry: an identity-pose call is normally
|
|
// treated as an odom reset (starts a new map); a NaN-or-null Transform must
|
|
// not crash and must produce some defined behavior.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, ProcessHandlesNullOdomPoseWithoutCrashing)
|
|
{
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(defaultRtabmapParams());
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
|
|
// First frame at a normal pose so the chain has a valid root.
|
|
SensorData first(image); first.setId(1);
|
|
ASSERT_TRUE(rtabmap.process(first, Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
const int firstId = rtabmap.getLastLocationId();
|
|
EXPECT_NE(firstId, 0);
|
|
|
|
// Now send a null Transform. process() should handle the degenerate input
|
|
// gracefully (typically by triggering an odom-reset path) rather than
|
|
// throwing or producing NaN-poisoned state.
|
|
SensorData second(image); second.setId(2);
|
|
rtabmap.process(second, Transform(), cov); // null Transform; ignore return
|
|
// State remains finite and queryable.
|
|
const Transform pose = rtabmap.getMapCorrection();
|
|
EXPECT_TRUE(std::isfinite(pose.x()));
|
|
EXPECT_TRUE(std::isfinite(pose.y()));
|
|
EXPECT_TRUE(std::isfinite(pose.z()));
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// kRGBDLoopCovLimited: when enabled, loop closure information matrices are
|
|
// clipped by Memory::getOdomMaxInf() before being applied via getInformation().
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_F(RtabmapFixture, GetInformationClipsCovarianceUnderLoopCovLimited)
|
|
{
|
|
// Reinit with loop-cov clipping enabled.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDLoopCovLimited()] = "true";
|
|
reinit(params);
|
|
process();
|
|
process();
|
|
|
|
// A tiny covariance would normally invert to a very large info matrix.
|
|
// With LoopCovLimited the value is clamped at Memory::getOdomMaxInf()
|
|
// (computed from the odom covariance seen so far).
|
|
cv::Mat tinyCov = cv::Mat::eye(6, 6, CV_64FC1) * 1e-12;
|
|
const cv::Mat info = rtabmap_->getInformation(tinyCov);
|
|
ASSERT_FALSE(info.empty());
|
|
// The largest diagonal entry should be finite (not 1e12) once clipped.
|
|
double maxDiag = 0.0;
|
|
for(int i = 0; i < 6; ++i) maxDiag = std::max(maxDiag, info.at<double>(i, i));
|
|
EXPECT_LT(maxDiag, 1e10) << "loop-cov clipping must bound the info matrix";
|
|
EXPECT_GT(maxDiag, 0.0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom user-defined link via Link::kUserClosure: external loop detectors
|
|
// can inject closures with this type and Memory must store them as such.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, AddLinkWithUserClosureTypeStoresIt)
|
|
{
|
|
// Disable OptimizeMaxError so the consistency check on the injected link
|
|
// doesn't reject it. Use non-adjacent nodes (N3 and N1) so we aren't
|
|
// fighting an existing kNeighbor odom link.
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kRGBDOptimizeMaxError()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
// Inject a user closure with the correct relative transform so the
|
|
// optimizer accepts it even if MaxError were re-enabled by accident.
|
|
const Link userLink(3, 1, Link::kUserClosure, Transform(-2.0f, 0, 0, 0, 0, 0),
|
|
cv::Mat::eye(6, 6, CV_64FC1) * 100.0);
|
|
ASSERT_TRUE(rtabmap.addLink(userLink));
|
|
|
|
const Signature * s3 = rtabmap.getMemory()->getSignature(3);
|
|
ASSERT_NE(s3, nullptr);
|
|
auto it = s3->getLinks().find(1);
|
|
ASSERT_NE(it, s3->getLinks().end());
|
|
EXPECT_EQ(it->second.type(), Link::kUserClosure)
|
|
<< "expected kUserClosure, got type=" << int(it->second.type());
|
|
rtabmap.close(false);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aggressive loop threshold (kRGBDAggressiveLoopThr): when set below the
|
|
// primary threshold, accepts marginal Bayes hypotheses in the pre-first-loc
|
|
// pre-localization window that the primary alone would reject. We A/B the
|
|
// same DB and same query frame against two threshold configs and verify the
|
|
// outcome differs in exactly the expected direction.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, AggressiveLoopThresholdAcceptsBelowPrimaryThreshold)
|
|
{
|
|
const std::string dbPath = uniqueDbPath();
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
const int kMatchSlot = 30;
|
|
|
|
// Mapping phase: same scaffolding as ProcessRejectsBadLoopClosure*.
|
|
{
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kRtabmapLoopThr()] = "0.05";
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(1, /*featSlot=*/0), Transform(0.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(2, /*featSlot=*/1), Transform(1.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(3, {kMatchSlot, kMatchSlot, 23, 23, 23, 23, 23, 23}),
|
|
Transform(2.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(makeFeaturesData(4, /*featSlot=*/kMatchSlot), Transform(3.0f, 0, 0, 0, 0, 0), cov));
|
|
ASSERT_TRUE(rtabmap.process(
|
|
makeFeaturesMixedSlots(5, {kMatchSlot, kMatchSlot, 25, 25, 25, 25, 25, 25}),
|
|
Transform(4.0f, 0, 0, 0, 0, 0), cov));
|
|
rtabmap.close(true);
|
|
}
|
|
|
|
const float kLoopThr = 0.95f; // would reject a weakened hypothesis
|
|
const float kAggressiveThr = 0.3f; // would accept it
|
|
|
|
// A localization session reuses the saved DB and feeds a weakened
|
|
// kMatchSlot query (6/8 features). Each invocation processes the SAME
|
|
// frame; only kRGBDAggressiveLoopThr differs.
|
|
auto runOnce = [&](float aggressiveThr) {
|
|
ParametersMap params = badLoopClosureParams();
|
|
params[Parameters::kMemIncrementalMemory()] = "false";
|
|
params[Parameters::kRtabmapLoopThr()] = uNumber2Str(kLoopThr);
|
|
params[Parameters::kRGBDAggressiveLoopThr()] = uNumber2Str(aggressiveThr);
|
|
params[Parameters::kBayesVirtualPlacePriorThr()] = "0.1";
|
|
// Commit the loop closure immediately rather than waiting for a 2nd
|
|
// confirmation; we want to observe the threshold gate result on the
|
|
// very first frame.
|
|
params[Parameters::kRGBDMaxOdomCacheSize()] = "0";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params, dbPath);
|
|
rtabmap.setInitialPose(Transform(3.0f, 0, 0, 0, 0, 0));
|
|
rtabmap.process(
|
|
makeFeaturesMixedSlots(0, {kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, kMatchSlot, 6, 6}),
|
|
Transform(3.0f, 0, 0, 0, 0, 0), cov);
|
|
const int id = rtabmap.getLoopClosureId();
|
|
const float high = rtabmap.getStatistics().data().at(Statistics::kLoopHighest_hypothesis_value());
|
|
rtabmap.close(false);
|
|
return std::make_pair(id, high);
|
|
};
|
|
|
|
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);
|
|
|
|
// The hypothesis must sit strictly between the two thresholds; otherwise
|
|
// the A/B comparison below is meaningless.
|
|
EXPECT_GT(highAgg, kAggressiveThr) << "hypothesis must clear the aggressive threshold";
|
|
EXPECT_LT(highAgg, kLoopThr) << "hypothesis must be below the primary threshold";
|
|
|
|
// With aggressive enabled (low) the loop is accepted; without (disabled
|
|
// by setting aggressive == primary) it is rejected.
|
|
EXPECT_EQ(idAgg, 4) << "aggressive threshold must accept the marginal hypothesis";
|
|
EXPECT_EQ(idPrim, 0) << "primary threshold alone must reject the same hypothesis";
|
|
|
|
UFile::erase(dbPath.c_str());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getGraph with full signature payloads (images / scan / user data / grid /
|
|
// words / global descriptors) - the path consumed by the ROS bridge.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST(RtabmapTest, GetGraphWithSignaturePayloadsAttachesRequestedFields)
|
|
{
|
|
ParametersMap params = defaultRtabmapParams();
|
|
params[Parameters::kMemBinDataKept()] = "true";
|
|
Rtabmap rtabmap;
|
|
rtabmap.init(params);
|
|
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
|
|
const cv::Mat cov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
|
|
for(int i = 1; i <= 3; ++i)
|
|
{
|
|
SensorData d(image); d.setId(i);
|
|
// Attach unique user data on each frame so we can verify pass-through.
|
|
cv::Mat userData(1, 4, CV_8UC1, cv::Scalar(0x10 + i));
|
|
d.setUserData(userData);
|
|
ASSERT_TRUE(rtabmap.process(d, Transform(float(i), 0, 0, 0, 0, 0), cov));
|
|
}
|
|
|
|
std::map<int, Transform> poses;
|
|
std::multimap<int, Link> links;
|
|
std::map<int, Signature> sigs;
|
|
rtabmap.getGraph(poses, links, /*optimized=*/true, /*global=*/false, &sigs,
|
|
/*withImages=*/true,
|
|
/*withScan=*/true,
|
|
/*withUserData=*/true,
|
|
/*withGrid=*/true,
|
|
/*withWords=*/true,
|
|
/*withGlobalDescriptors=*/true);
|
|
|
|
ASSERT_EQ(sigs.size(), 3u);
|
|
for(const auto & kv : sigs)
|
|
{
|
|
const Signature & s = kv.second;
|
|
// Image was provided to every frame so the payload must come through.
|
|
EXPECT_GT(s.sensorData().imageCompressed().total(), 0u) << "node " << kv.first << " image missing";
|
|
EXPECT_GT(s.sensorData().userDataCompressed().total(), 0u) << "node " << kv.first << " user data missing";
|
|
}
|
|
rtabmap.close(false);
|
|
}
|
|
|