Files
rtabmap/corelib/test/test_memory.cpp

4163 lines
156 KiB
C++
Raw Normal View History

Adding doc and tests (#1492) * 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)
2026-08-06 13:32:20 -07:00
#include <gtest/gtest.h>
#include <rtabmap/core/CameraModel.h>
#include <rtabmap/core/Compression.h>
#include <rtabmap/core/DBDriver.h>
#include <rtabmap/core/Landmark.h>
#include <rtabmap/core/LaserScan.h>
#include <rtabmap/core/Memory.h>
#include <rtabmap/core/Link.h>
#include <rtabmap/core/Parameters.h>
#include <rtabmap/core/RegistrationInfo.h>
#include <rtabmap/core/util3d_transforms.h>
Adding doc and tests (#1492) * 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)
2026-08-06 13:32:20 -07:00
#include <rtabmap/core/SensorData.h>
#include <rtabmap/core/Signature.h>
#include <rtabmap/core/Transform.h>
#include <rtabmap/core/VWDictionary.h>
#include <rtabmap/core/VisualWord.h>
#include <rtabmap/utilite/UFile.h>
#include <rtabmap/utilite/UConversion.h>
#include <rtabmap/utilite/ULogger.h>
#include "TestUtils.h"
#include <cstdio>
#include <string>
#include <vector>
using namespace rtabmap;
namespace {
// Build a Memory that does not extract visual features (kKpMaxFeatures=-1).
// Tests focus on Memory's graph/STM/WM bookkeeping, not on Feature2D, VWDictionary
// or Registration, which have their own dedicated tests.
ParametersMap defaultMemoryParams(int stmSize = 5)
{
ParametersMap params;
params.insert(ParametersPair(Parameters::kKpMaxFeatures(), "-1"));
params.insert(ParametersPair(Parameters::kMemSTMSize(), uNumber2Str(stmSize).c_str()));
params.insert(ParametersPair(Parameters::kMemRehearsalSimilarity(), "1.0")); // disable rehearsal merge
params.insert(ParametersPair(Parameters::kMemBinDataKept(), "false"));
params.insert(ParametersPair(Parameters::kMemBadSignaturesIgnored(), "false"));
return params;
}
std::string uniqueDbPath()
{
static int counter = 0;
return test::tempPath(uFormat("rtabmap_memory_test_%d_%d.db", test::getPid(), ++counter));
}
class MemoryFixture : 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;
memory_ = new Memory(defaultMemoryParams());
}
void TearDown() override
{
delete memory_;
memory_ = nullptr;
}
void reinit(const ParametersMap & params)
{
delete memory_;
memory_ = new Memory(params);
}
bool update(int /*idHint*/ = 0)
{
SensorData data(image_);
Transform pose(float(updateCount_), 0.0f, 0.0f, 0, 0, 0);
bool ok = memory_->update(data, pose, covariance_);
++updateCount_;
return ok;
}
bool updateWith(const Transform & pose)
{
SensorData data(image_);
bool ok = memory_->update(data, pose, covariance_);
++updateCount_;
return ok;
}
cv::Mat image_;
cv::Mat covariance_;
Memory * memory_ = nullptr;
int updateCount_ = 0;
};
} // namespace
// ---------------------------------------------------------------------------
// Constructor / parseParameters
// ---------------------------------------------------------------------------
TEST(MemoryTest, DefaultConstructor)
{
Memory memory;
EXPECT_EQ(memory.getStMem().size(), 0u);
EXPECT_EQ(memory.getWorkingMem().size(), 0u);
EXPECT_FALSE(memory.memoryChanged());
EXPECT_EQ(memory.getLastSignatureId(), 0);
EXPECT_EQ(memory.getMaxStMemSize(), (int)Parameters::defaultMemSTMSize());
EXPECT_TRUE(memory.isIncremental());
}
TEST(MemoryTest, ParameterizedConstructor)
{
ParametersMap params;
params.insert(ParametersPair(Parameters::kMemSTMSize(), "3"));
params.insert(ParametersPair(Parameters::kMemIncrementalMemory(), "false"));
params.insert(ParametersPair(Parameters::kMemLocalizationDataSaved(), "true"));
Memory memory(params);
EXPECT_EQ(memory.getMaxStMemSize(), 3);
EXPECT_FALSE(memory.isIncremental());
EXPECT_TRUE(memory.isLocalizationDataSaved());
}
TEST(MemoryTest, ConstantIds)
{
// kIdStart is the seed of Memory::_idCount; Memory::getNextId() pre-increments it,
// so the first generated id is kIdStart + 1.
// kIdVirtual is the negative id used by the Bayes filter for the "new place" hypothesis.
// kIdInvalid is the sentinel returned when no id is available.
EXPECT_EQ(Memory::kIdStart, 0);
EXPECT_EQ(Memory::kIdVirtual, -1);
EXPECT_EQ(Memory::kIdInvalid, 0);
}
TEST(MemoryTest, ParseParametersUpdatesSettings)
{
Memory memory(defaultMemoryParams(3));
EXPECT_EQ(memory.getMaxStMemSize(), 3);
ParametersMap newParams;
newParams.insert(ParametersPair(Parameters::kMemSTMSize(), "7"));
memory.parseParameters(newParams);
EXPECT_EQ(memory.getMaxStMemSize(), 7);
}
TEST(MemoryTest, GetParametersReflectsLastApplied)
{
ParametersMap params = defaultMemoryParams(4);
Memory memory(params);
const ParametersMap & got = memory.getParameters();
ASSERT_TRUE(got.count(Parameters::kMemSTMSize()) > 0);
EXPECT_EQ(got.at(Parameters::kMemSTMSize()), std::string("4"));
}
// ---------------------------------------------------------------------------
// init / close
// ---------------------------------------------------------------------------
TEST(MemoryTest, InitInMemoryDatabase)
{
Memory memory(defaultMemoryParams());
EXPECT_TRUE(memory.init("")); // empty url == in-memory
EXPECT_NE(memory.getDatabaseVersion(), std::string(""));
memory.close(false);
}
TEST(MemoryTest, InitFileDatabaseCreatesFile)
{
const std::string dbPath = uniqueDbPath();
{
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(dbPath, true));
EXPECT_EQ(memory.getDatabaseUrl(), dbPath);
memory.close(true);
}
EXPECT_TRUE(UFile::exists(dbPath.c_str()));
UFile::erase(dbPath.c_str());
}
TEST(MemoryTest, CloseWithoutInitIsSafe)
{
Memory memory(defaultMemoryParams());
// Should not crash without init().
memory.close(false);
}
// ---------------------------------------------------------------------------
// update() / STM / WM
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, UpdateAddsSignatureToStm)
{
ASSERT_TRUE(update());
EXPECT_EQ(memory_->getStMem().size(), 1u);
EXPECT_EQ(memory_->getWorkingMem().size(), 0u);
const int id = memory_->getLastSignatureId();
// First generated id is kIdStart + 1 = 1.
EXPECT_EQ(id, Memory::kIdStart + 1);
EXPECT_TRUE(memory_->isInSTM(id));
EXPECT_FALSE(memory_->isInWM(id));
EXPECT_FALSE(memory_->isInLTM(id));
}
TEST_F(MemoryFixture, UpdateAssignsSequentialIds)
{
ASSERT_TRUE(update());
const int id1 = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int id2 = memory_->getLastSignatureId();
EXPECT_EQ(id2, id1 + 1);
}
TEST_F(MemoryFixture, StmCapacityPromotesOldestToWm)
{
const int stmSize = memory_->getMaxStMemSize(); // 5
std::vector<int> ids;
for(int i = 0; i < stmSize + 2; ++i)
{
ASSERT_TRUE(update());
ids.push_back(memory_->getLastSignatureId());
}
// Only the last stmSize ids remain in STM.
EXPECT_EQ((int)memory_->getStMem().size(), stmSize);
// The 2 oldest are in WM.
for(int i = 0; i < 2; ++i)
{
EXPECT_TRUE(memory_->isInWM(ids[i])) << "id=" << ids[i];
EXPECT_FALSE(memory_->isInSTM(ids[i]));
}
// The newest stmSize are in STM.
for(int i = 2; i < stmSize + 2; ++i)
{
EXPECT_TRUE(memory_->isInSTM(ids[i])) << "id=" << ids[i];
EXPECT_FALSE(memory_->isInWM(ids[i]));
}
}
TEST_F(MemoryFixture, NeighborLinkConnectsConsecutiveSignatures)
{
ASSERT_TRUE(update());
const int id1 = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int id2 = memory_->getLastSignatureId();
std::multimap<int, Link> links = memory_->getNeighborLinks(id2);
ASSERT_EQ(links.size(), 1u);
EXPECT_EQ(links.begin()->first, id1);
EXPECT_EQ(links.begin()->second.type(), Link::kNeighbor);
}
TEST_F(MemoryFixture, GetSignatureReturnsLoadedSignature)
{
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
const Signature * s = memory_->getSignature(id);
ASSERT_NE(s, nullptr);
EXPECT_EQ(s->id(), id);
}
TEST_F(MemoryFixture, GetSignatureReturnsNullForUnknownId)
{
EXPECT_EQ(memory_->getSignature(999), nullptr);
}
TEST_F(MemoryFixture, GetLastWorkingSignatureSkipsIntermediate)
{
ASSERT_TRUE(update());
const int id1 = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int id2 = memory_->getLastSignatureId();
memory_->convertToIntermediate(id2);
const Signature * last = memory_->getLastWorkingSignature(true);
ASSERT_NE(last, nullptr);
EXPECT_EQ(last->id(), id1);
const Signature * lastIncludingIntermediate = memory_->getLastWorkingSignature(false);
ASSERT_NE(lastIncludingIntermediate, nullptr);
EXPECT_EQ(lastIncludingIntermediate->id(), id2);
}
TEST_F(MemoryFixture, AllSignatureIdsReturnsLoadedIds)
{
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
EXPECT_EQ(ids.size(), 3u);
}
// ---------------------------------------------------------------------------
// memoryChanged / mode flags
// ---------------------------------------------------------------------------
TEST(MemoryTest, MemoryChangedInitiallyFalse)
{
Memory memory(defaultMemoryParams());
EXPECT_FALSE(memory.memoryChanged());
}
TEST_F(MemoryFixture, MemoryChangedTrueAfterUpdateInMappingMode)
{
ASSERT_TRUE(update());
EXPECT_TRUE(memory_->memoryChanged());
}
TEST(MemoryTest, MemoryChangedRemainsFalseInPureLocalizationMode)
{
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemIncrementalMemory()] = "false";
params[Parameters::kMemLocalizationDataSaved()] = "false";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance);
EXPECT_FALSE(memory.memoryChanged());
}
TEST(MemoryTest, IsIncrementalFromParameter)
{
ParametersMap mapping = defaultMemoryParams();
mapping[Parameters::kMemIncrementalMemory()] = "true";
EXPECT_TRUE(Memory(mapping).isIncremental());
ParametersMap loc = defaultMemoryParams();
loc[Parameters::kMemIncrementalMemory()] = "false";
EXPECT_FALSE(Memory(loc).isIncremental());
}
TEST(MemoryTest, IsReadOnlyOnlyInLocalizationMode)
{
ParametersMap mapping = defaultMemoryParams();
mapping[Parameters::kMemIncrementalMemory()] = "true";
mapping[Parameters::kMemLocalizationReadOnly()] = "true"; // ignored in mapping mode
EXPECT_FALSE(Memory(mapping).isReadOnly());
ParametersMap localizationRW = defaultMemoryParams();
localizationRW[Parameters::kMemIncrementalMemory()] = "false";
localizationRW[Parameters::kMemLocalizationReadOnly()] = "false";
EXPECT_FALSE(Memory(localizationRW).isReadOnly());
ParametersMap localizationRO = defaultMemoryParams();
localizationRO[Parameters::kMemIncrementalMemory()] = "false";
localizationRO[Parameters::kMemLocalizationReadOnly()] = "true";
EXPECT_TRUE(Memory(localizationRO).isReadOnly());
}
// ---------------------------------------------------------------------------
// Links
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, AddLinkBetweenWmNodes)
{
for(int i = 0; i < 4; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
ASSERT_EQ(ids.size(), 4u);
int idA = *ids.begin();
int idB = *ids.rbegin();
Link link(idA, idB, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1));
EXPECT_TRUE(memory_->addLink(link));
std::multimap<int, Link> loops = memory_->getLoopClosureLinks(idA);
ASSERT_EQ(loops.size(), 1u);
EXPECT_EQ(loops.begin()->first, idB);
EXPECT_EQ(loops.begin()->second.type(), Link::kGlobalClosure);
}
TEST_F(MemoryFixture, GetLinksReturnsBothNeighborAndLoop)
{
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
int idA = *ids.begin();
int idC = *ids.rbegin();
int idB = *(++ids.begin());
// idA <-> idB (neighbor, auto-added), idB <-> idC (neighbor, auto-added).
// Add a loop closure idA <-> idC.
memory_->addLink(Link(idA, idC, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1)));
std::multimap<int, Link> allLinks = memory_->getLinks(idA);
// idA has neighbor link to idB and loop link to idC.
bool foundNeighbor = false, foundLoop = false;
for(std::multimap<int, Link>::iterator it = allLinks.begin(); it != allLinks.end(); ++it)
{
if(it->second.type() == Link::kNeighbor && it->first == idB) foundNeighbor = true;
if(it->second.type() == Link::kGlobalClosure && it->first == idC) foundLoop = true;
}
EXPECT_TRUE(foundNeighbor);
EXPECT_TRUE(foundLoop);
}
TEST_F(MemoryFixture, UpdateLinkChangesTransform)
{
for(int i = 0; i < 2; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
int idA = *ids.begin();
int idB = *ids.rbegin();
memory_->addLink(Link(idA, idB, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1)));
Transform updated(2.5f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
memory_->updateLink(Link(idA, idB, Link::kGlobalClosure, updated, cv::Mat::eye(6, 6, CV_64FC1)));
std::multimap<int, Link> loops = memory_->getLoopClosureLinks(idA);
ASSERT_EQ(loops.size(), 1u);
EXPECT_NEAR(loops.begin()->second.transform().x(), 2.5f, 1e-5f);
}
TEST_F(MemoryFixture, RemoveLinkBreaksBothDirections)
{
for(int i = 0; i < 2; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
int idA = *ids.begin();
int idB = *ids.rbegin();
// Neighbor link was added implicitly; remove it.
memory_->removeLink(idA, idB);
EXPECT_EQ(memory_->getNeighborLinks(idA).size(), 0u);
EXPECT_EQ(memory_->getNeighborLinks(idB).size(), 0u);
}
TEST_F(MemoryFixture, RemoveAllVirtualLinks)
{
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
int idA = *ids.begin();
int idC = *ids.rbegin();
memory_->addLink(Link(idA, idC, Link::kVirtualClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1)));
std::multimap<int, Link> linksBefore = memory_->getLinks(idA);
bool hadVirtual = false;
for(auto & p: linksBefore) { if(p.second.type() == Link::kVirtualClosure) hadVirtual = true; }
ASSERT_TRUE(hadVirtual);
memory_->removeAllVirtualLinks();
std::multimap<int, Link> linksAfter = memory_->getLinks(idA);
for(auto & p: linksAfter)
{
EXPECT_NE(p.second.type(), Link::kVirtualClosure);
}
}
// ---------------------------------------------------------------------------
// Graph queries (getNeighborsId)
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetNeighborsIdLinearChain)
{
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
const int start = *ids.begin();
// getNeighborsId loops while m < maxGraphDepth, so depth=N walks to depth N-1.
// depth=3 from a chain start visits {start (m=0), neighbor (m=1), 2nd neighbor (m=2)}.
std::map<int, int> neighbors = memory_->getNeighborsId(start, 3);
EXPECT_EQ(neighbors.size(), 3u);
EXPECT_EQ(neighbors.at(start), 0);
}
TEST_F(MemoryFixture, GetNeighborsIdIntermediateNodesAreTransparentDepthWise)
{
// Build a 5-node chain {A, B, C, D, E} and mark C (the middle node) as intermediate.
// Intermediate nodes do not consume a depth level when traversed: their neighbors are
// pushed to the *current* margin, and intermediates themselves are reported with
// effectiveMargin = m - 1. As a result, starting at A with depth=3 reaches D (which
// would normally sit at margin 3, i.e. outside the bound) because C "collapses".
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
std::vector<int> chain(ids.begin(), ids.end()); // sorted ascending = chain order
ASSERT_EQ(chain.size(), 5u);
const int A = chain[0];
const int B = chain[1];
const int C = chain[2];
const int D = chain[3];
const int E = chain[4];
memory_->convertToIntermediate(C);
ASSERT_EQ(memory_->getSignature(C)->getWeight(), -1);
// Default (ignoreIntermediateNodes=false): C is included, with effective margin = m-1
// (so margin 1 instead of 2). D is reached at margin 2. E is still outside.
std::map<int, int> withIntermediate = memory_->getNeighborsId(
A,
/*maxGraphDepth=*/3,
/*maxCheckedInDatabase=*/0,
/*incrementMarginOnLoop=*/false,
/*ignoreLoopIds=*/false,
/*ignoreIntermediateNodes=*/false);
EXPECT_EQ(withIntermediate.size(), 4u);
EXPECT_EQ(withIntermediate.at(A), 0);
EXPECT_EQ(withIntermediate.at(B), 1);
EXPECT_EQ(withIntermediate.at(C), 1); // effective margin (would be 2 without the -1 rule)
EXPECT_EQ(withIntermediate.at(D), 2);
EXPECT_TRUE(withIntermediate.find(E) == withIntermediate.end());
// ignoreIntermediateNodes=true: C is dropped from the result, but traversal still
// "passes through" it, so D is reached and E remains outside.
std::map<int, int> ignoringIntermediate = memory_->getNeighborsId(
A,
/*maxGraphDepth=*/3,
/*maxCheckedInDatabase=*/0,
/*incrementMarginOnLoop=*/false,
/*ignoreLoopIds=*/false,
/*ignoreIntermediateNodes=*/true);
EXPECT_EQ(ignoringIntermediate.size(), 3u);
EXPECT_EQ(ignoringIntermediate.at(A), 0);
EXPECT_EQ(ignoringIntermediate.at(B), 1);
EXPECT_TRUE(ignoringIntermediate.find(C) == ignoringIntermediate.end());
EXPECT_EQ(ignoringIntermediate.at(D), 2);
EXPECT_TRUE(ignoringIntermediate.find(E) == ignoringIntermediate.end());
// Sanity check: without any intermediate node, the same depth=3 walk would stop at C
// (margin 2) and never see D. We verify this by restoring a fresh chain and replaying.
reinit(defaultMemoryParams(/*stmSize=*/5));
updateCount_ = 0;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> plainIds = memory_->getAllSignatureIds();
std::vector<int> plainChain(plainIds.begin(), plainIds.end());
const int plainA = plainChain[0];
const int plainC = plainChain[2];
const int plainD = plainChain[3];
std::map<int, int> plain = memory_->getNeighborsId(plainA, 3);
EXPECT_EQ(plain.size(), 3u);
EXPECT_EQ(plain.at(plainC), 2);
EXPECT_TRUE(plain.find(plainD) == plain.end());
}
TEST_F(MemoryFixture, GetNeighborsIdDepthZeroMeansUnbounded)
{
for(int i = 0; i < 4; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
const int start = *ids.begin();
std::map<int, int> all = memory_->getNeighborsId(start, 0);
EXPECT_EQ(all.size(), ids.size());
}
TEST_F(MemoryFixture, GetNeighborsIdIgnoreLoopIds)
{
for(int i = 0; i < 4; ++i)
{
ASSERT_TRUE(update());
}
std::set<int> ids = memory_->getAllSignatureIds();
const int idA = *ids.begin();
const int idLast = *ids.rbegin();
// Add a loop closure shortcut idA -> idLast.
memory_->addLink(Link(idA, idLast, Link::kGlobalClosure, Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1)));
std::map<int, int> withLoops = memory_->getNeighborsId(idA, 1);
std::map<int, int> withoutLoops = memory_->getNeighborsId(idA, 1, -1, false, true /* ignoreLoopIds */);
// idLast is reachable via loop link at depth 1 -> present without ignoring loops.
EXPECT_TRUE(withLoops.count(idLast) > 0);
// When ignoring loop ids, idLast at depth 1 is no longer reachable.
EXPECT_TRUE(withoutLoops.count(idLast) == 0);
}
// ---------------------------------------------------------------------------
// Labels
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, LabelSignatureAndLookup)
{
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
EXPECT_TRUE(memory_->labelSignature(id, "kitchen"));
EXPECT_EQ(memory_->getSignatureIdByLabel("kitchen", false), id);
const std::map<int, std::string> & labels = memory_->getAllLabels();
ASSERT_EQ(labels.size(), 1u);
EXPECT_EQ(labels.at(id), std::string("kitchen"));
}
TEST_F(MemoryFixture, LabelSignatureRejectsDuplicate)
{
ASSERT_TRUE(update());
const int id1 = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int id2 = memory_->getLastSignatureId();
ASSERT_TRUE(memory_->labelSignature(id1, "office"));
// Same label on a different id is rejected.
EXPECT_FALSE(memory_->labelSignature(id2, "office"));
}
TEST_F(MemoryFixture, LabelSignatureRemoveByEmptyString)
{
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
memory_->labelSignature(id, "lobby");
ASSERT_EQ(memory_->getAllLabels().size(), 1u);
EXPECT_TRUE(memory_->labelSignature(id, ""));
EXPECT_EQ(memory_->getAllLabels().size(), 0u);
}
// ---------------------------------------------------------------------------
// Intermediate nodes / deletion
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, ConvertToIntermediateSetsWeightMinusOne)
{
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
memory_->convertToIntermediate(id);
const Signature * s = memory_->getSignature(id);
ASSERT_NE(s, nullptr);
EXPECT_EQ(s->getWeight(), -1);
}
TEST(MemoryTest, WorkingMemSizeAndIntermediateNodeCounters)
{
// getWorkingMemSize() reports the WM size without the virtual place, optionally
// without the intermediate nodes (weight=-1) too. The two counters backing it are
// maintained incrementally at every place a node enters/leaves STM or WM, so this
// walks a node through all of them: added to STM, converted to intermediate,
// transferred STM->WM, then deleted.
Memory memory(defaultMemoryParams(/*stmSize=*/2));
ASSERT_TRUE(memory.init(""));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
int updateCount = 0;
auto addNode = [&]() {
SensorData data(image);
EXPECT_TRUE(memory.update(data, Transform(float(updateCount++), 0.0f, 0.0f, 0, 0, 0), covariance));
return memory.getLastSignatureId();
};
// Only the virtual place is in WM at this point.
EXPECT_EQ(memory.getWorkingMemSize(false), 0u);
EXPECT_EQ(memory.getWorkingMemSize(true), 0u);
EXPECT_EQ(memory.getWorkingMemIntermediateNodesCount(), 0);
EXPECT_EQ(memory.getStMemIntermediateNodesCount(), 0);
const int A = addNode();
const int B = addNode();
ASSERT_TRUE(memory.isInSTM(A));
ASSERT_TRUE(memory.isInSTM(B));
EXPECT_EQ(memory.getWorkingMemSize(false), 0u);
// STM is full (size 2), so the oldest node moves to WM.
const int C = addNode();
ASSERT_TRUE(memory.isInWM(A));
ASSERT_TRUE(memory.isInSTM(B));
ASSERT_TRUE(memory.isInSTM(C));
EXPECT_EQ(memory.getWorkingMemSize(false), 1u);
EXPECT_EQ(memory.getWorkingMemSize(true), 1u);
// Converting a WM node bumps the WM counter, and only the "ignore intermediate"
// flavour of getWorkingMemSize() reacts.
memory.convertToIntermediate(A);
EXPECT_EQ(memory.getWorkingMemIntermediateNodesCount(), 1);
EXPECT_EQ(memory.getStMemIntermediateNodesCount(), 0);
EXPECT_EQ(memory.getWorkingMemSize(false), 1u);
EXPECT_EQ(memory.getWorkingMemSize(true), 0u);
// Converting an STM node bumps the STM counter instead.
memory.convertToIntermediate(B);
EXPECT_EQ(memory.getWorkingMemIntermediateNodesCount(), 1);
EXPECT_EQ(memory.getStMemIntermediateNodesCount(), 1);
// The STM budget itself only counts non-intermediate nodes, so this node does
// not push B out even though STM now holds three signatures.
addNode();
EXPECT_TRUE(memory.isInSTM(B));
EXPECT_EQ(memory.getStMemIntermediateNodesCount(), 1);
// One more non-intermediate node exceeds the budget: B (oldest) is transferred
// first, and since it doesn't count against the budget C follows immediately.
addNode();
ASSERT_TRUE(memory.isInWM(B));
ASSERT_TRUE(memory.isInWM(C));
// The tally moved with B from STM to WM.
EXPECT_EQ(memory.getWorkingMemIntermediateNodesCount(), 2);
EXPECT_EQ(memory.getStMemIntermediateNodesCount(), 0);
EXPECT_EQ(memory.getWorkingMemSize(false), 3u);
EXPECT_EQ(memory.getWorkingMemSize(true), 1u); // only C is a regular node
// Removing an intermediate node decrements the WM counter.
memory.deleteLocation(A);
EXPECT_EQ(memory.getWorkingMemIntermediateNodesCount(), 1);
EXPECT_EQ(memory.getWorkingMemSize(false), 2u);
EXPECT_EQ(memory.getWorkingMemSize(true), 1u);
memory.close(false);
}
TEST(MemoryTest, ConvertToIntermediateClearsAllPayloadsByDefault)
{
// kMemBinDataKept=true so update() leaves compressed binary data in the signature
// (with default kMemBinDataKept=false the fixture has already cleared it).
// kMemIntermediateNodeDataKept=false (default) so convertToIntermediate() must also
// wipe words, global descriptors and compressed binary data.
ParametersMap params = defaultMemoryParams(5);
params[Parameters::kMemBinDataKept()] = "true";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
const Signature * sConst = memory.getSignature(id);
ASSERT_NE(sConst, nullptr);
// Inject synthetic features, words and a global descriptor via a writable handle on
// the signature, so we can verify the cleanup branches that fire when these payloads
// are present. Memory keeps internal Signature* pointers; const_cast is safe here
// because the storage is the same object exposed by getSignature().
Signature * s = const_cast<Signature *>(sConst);
std::vector<cv::KeyPoint> kpts(2, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(2, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::ones(2, 8, CV_32F);
s->sensorData().setFeatures(kpts, pts3, descriptors);
std::multimap<int, int> wordIds;
wordIds.insert(std::make_pair(11, 0));
wordIds.insert(std::make_pair(22, 1));
s->setWords(wordIds, kpts, pts3, descriptors);
s->sensorData().addGlobalDescriptor(GlobalDescriptor(0, cv::Mat::ones(1, 4, CV_32F)));
// Sanity-check the pre-state: 2 keypoints / 3D points / descriptors / words, 1
// global descriptor, and a single compressed image row from update().
ASSERT_EQ(s->sensorData().imageCompressed().rows, 1)
<< "update() should have produced compressed binary data";
ASSERT_EQ(s->sensorData().keypoints().size(), 2u);
ASSERT_EQ(s->sensorData().descriptors().rows, 2);
ASSERT_EQ(s->getWords().size(), 2u);
ASSERT_EQ(s->getWordsKpts().size(), 2u);
ASSERT_EQ(s->getWords3().size(), 2u);
ASSERT_EQ(s->getWordsDescriptors().rows, 2);
ASSERT_EQ(s->sensorData().globalDescriptors().size(), 1u);
memory.convertToIntermediate(id);
// Re-fetch after conversion (same Signature* in practice, but re-look up for clarity).
const Signature * after = memory.getSignature(id);
ASSERT_NE(after, nullptr);
// 1) Weight is the intermediate sentinel.
EXPECT_EQ(after->getWeight(), -1);
// 2) sensorData().setFeatures(empty, empty, cv::Mat()) -> features wiped.
EXPECT_EQ(after->sensorData().keypoints().size(), 0u);
EXPECT_EQ(after->sensorData().keypoints3D().size(), 0u);
EXPECT_EQ(after->sensorData().descriptors().rows, 0);
// 3) Visual words / word kpts / 3D word points / word descriptors wiped
// (removeAllWords() because kMemIntermediateNodeDataKept=false).
EXPECT_EQ(after->getWords().size(), 0u);
EXPECT_EQ(after->getWordsKpts().size(), 0u);
EXPECT_EQ(after->getWords3().size(), 0u);
EXPECT_EQ(after->getWordsDescriptors().rows, 0);
// 4) Global descriptors wiped (clearGlobalDescriptors() because intermediate data not kept).
EXPECT_EQ(after->sensorData().globalDescriptors().size(), 0u);
// 5) Raw RGB/depth/scan wiped (clearRawData(), always).
EXPECT_EQ(after->sensorData().imageRaw().rows, 0);
EXPECT_EQ(after->sensorData().depthOrRightRaw().rows, 0);
EXPECT_TRUE(after->sensorData().laserScanRaw().size() == 0);
// 6) Compressed RGB/depth/scan wiped (clearCompressedData() because !binDataKept).
EXPECT_EQ(after->sensorData().imageCompressed().rows, 0);
EXPECT_EQ(after->sensorData().depthOrRightCompressed().rows, 0);
EXPECT_TRUE(after->sensorData().laserScanCompressed().size() == 0);
}
TEST(MemoryTest, ConvertToIntermediateClearsAllPayloadsWhenBothFlagsFalse)
{
// kMemBinDataKept=false AND kMemIntermediateNodeDataKept=false (the fixture default).
// In this config update() already discards compressed binary data, so we re-inject
// compressed bytes into the signature's SensorData to exercise the clearCompressedData()
// branch of convertToIntermediate() rather than have it be a trivial no-op.
ParametersMap params = defaultMemoryParams(5);
params[Parameters::kMemBinDataKept()] = "false";
// kMemIntermediateNodeDataKept already defaults to false.
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
Signature * s = const_cast<Signature *>(memory.getSignature(id));
ASSERT_NE(s, nullptr);
// After update() with binDataKept=false, the compressed image is already empty.
ASSERT_EQ(s->sensorData().imageCompressed().rows, 0);
// Re-inject compressed image bytes by feeding a 1xN CV_8UC1 row matrix to setRGBDImage,
// which is the documented "already-compressed" path for SensorData.
cv::Mat compressedRgb = compressData2(image);
ASSERT_EQ(compressedRgb.rows, 1);
ASSERT_EQ(compressedRgb.type(), CV_8UC1);
s->sensorData().setRGBDImage(compressedRgb, cv::Mat(), CameraModel(), /*clearPreviousData=*/false);
ASSERT_EQ(s->sensorData().imageCompressed().rows, 1);
// Also seed features, words and a global descriptor so every cleanup branch fires.
std::vector<cv::KeyPoint> kpts(2, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(2, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::ones(2, 8, CV_32F);
s->sensorData().setFeatures(kpts, pts3, descriptors);
std::multimap<int, int> wordIds;
wordIds.insert(std::make_pair(11, 0));
wordIds.insert(std::make_pair(22, 1));
s->setWords(wordIds, kpts, pts3, descriptors);
s->sensorData().addGlobalDescriptor(GlobalDescriptor(0, cv::Mat::ones(1, 4, CV_32F)));
ASSERT_EQ(s->sensorData().keypoints().size(), 2u);
ASSERT_EQ(s->getWords().size(), 2u);
ASSERT_EQ(s->sensorData().globalDescriptors().size(), 1u);
memory.convertToIntermediate(id);
const Signature * after = memory.getSignature(id);
ASSERT_NE(after, nullptr);
// Weight + features always cleared.
EXPECT_EQ(after->getWeight(), -1);
EXPECT_EQ(after->sensorData().keypoints().size(), 0u);
EXPECT_EQ(after->sensorData().keypoints3D().size(), 0u);
EXPECT_EQ(after->sensorData().descriptors().rows, 0);
// !_saveIntermediateNodeData -> words + global descriptors cleared.
EXPECT_EQ(after->getWords().size(), 0u);
EXPECT_EQ(after->getWordsKpts().size(), 0u);
EXPECT_EQ(after->getWords3().size(), 0u);
EXPECT_EQ(after->getWordsDescriptors().rows, 0);
EXPECT_EQ(after->sensorData().globalDescriptors().size(), 0u);
// clearRawData() unconditional.
EXPECT_EQ(after->sensorData().imageRaw().rows, 0);
EXPECT_EQ(after->sensorData().depthOrRightRaw().rows, 0);
EXPECT_TRUE(after->sensorData().laserScanRaw().size() == 0);
// (!_saveIntermediateNodeData || !_binDataKept) -> compressed binary data cleared.
// Both branches are true here, so the OR is true and the injected compressed image
// is wiped.
EXPECT_EQ(after->sensorData().imageCompressed().rows, 0);
EXPECT_EQ(after->sensorData().depthOrRightCompressed().rows, 0);
EXPECT_TRUE(after->sensorData().laserScanCompressed().size() == 0);
}
TEST(MemoryTest, ConvertToIntermediateKeepsDataWhenIntermediateNodeDataKept)
{
// kMemIntermediateNodeDataKept=true: words, global descriptors, and (when binDataKept=true)
// compressed binary data must survive convertToIntermediate(). Features and raw data are
// still always wiped because their clearing is unconditional.
ParametersMap params = defaultMemoryParams(5);
params[Parameters::kMemBinDataKept()] = "true";
params[Parameters::kMemIntermediateNodeDataKept()] = "true";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
Signature * s = const_cast<Signature *>(memory.getSignature(id));
ASSERT_NE(s, nullptr);
std::vector<cv::KeyPoint> kpts(2, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(2, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::ones(2, 8, CV_32F);
std::multimap<int, int> wordIds;
wordIds.insert(std::make_pair(11, 0));
wordIds.insert(std::make_pair(22, 1));
s->sensorData().setFeatures(kpts, pts3, descriptors);
s->setWords(wordIds, kpts, pts3, descriptors);
s->sensorData().addGlobalDescriptor(GlobalDescriptor(0, cv::Mat::ones(1, 4, CV_32F)));
ASSERT_EQ(s->sensorData().imageCompressed().rows, 1);
memory.convertToIntermediate(id);
const Signature * after = memory.getSignature(id);
ASSERT_NE(after, nullptr);
// Always-wiped: weight, features, raw data.
EXPECT_EQ(after->getWeight(), -1);
EXPECT_EQ(after->sensorData().keypoints().size(), 0u);
EXPECT_EQ(after->sensorData().keypoints3D().size(), 0u);
EXPECT_EQ(after->sensorData().descriptors().rows, 0);
EXPECT_EQ(after->sensorData().imageRaw().rows, 0);
// Conditionally kept: words, global descriptors, compressed binary data.
EXPECT_EQ(after->getWords().size(), 2u);
EXPECT_EQ(after->getWordsDescriptors().rows, 2);
EXPECT_EQ(after->sensorData().globalDescriptors().size(), 1u);
EXPECT_EQ(after->sensorData().imageCompressed().rows, 1);
}
TEST_F(MemoryFixture, DeleteLocationRemovesFromWorkingMem)
{
for(int i = 0; i < 2; ++i)
{
ASSERT_TRUE(update());
}
const int id = memory_->getLastSignatureId();
memory_->deleteLocation(id);
EXPECT_EQ(memory_->getSignature(id), nullptr);
EXPECT_FALSE(memory_->isInSTM(id));
EXPECT_FALSE(memory_->isInWM(id));
}
// ---------------------------------------------------------------------------
// moveToTrash (exercised through deleteLocation, which calls
// moveToTrash(s, /*keepLinkedToGraph=*/false, deletedWords))
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, MoveToTrashBreaksNeighborLinksOnPeers)
{
// Build a chain A-B-C. deleteLocation(B) calls moveToTrash with keepLinkedToGraph=false,
// which iterates B's links and calls sTo->removeLink(B) on every peer, so A and C
// must lose their kNeighbor links to B.
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
const std::set<int> idsSet = memory_->getAllSignatureIds();
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int A = ids[0];
const int B = ids[1];
const int C = ids[2];
const Signature * aS = memory_->getSignature(A);
const Signature * cS = memory_->getSignature(C);
ASSERT_TRUE(aS->hasLink(B));
ASSERT_TRUE(cS->hasLink(B));
memory_->deleteLocation(B);
EXPECT_EQ(memory_->getSignature(B), nullptr);
aS = memory_->getSignature(A);
cS = memory_->getSignature(C);
ASSERT_NE(aS, nullptr);
ASSERT_NE(cS, nullptr);
EXPECT_FALSE(aS->hasLink(B));
EXPECT_FALSE(cS->hasLink(B));
}
TEST(MemoryTest, DeleteLocationKeepLinkedInDbPreservesLinksInLtm)
{
// deleteLocation(id, deletedWords, keepLinkedInDb) forwards keepLinkedInDb as
// moveToTrash's keepLinkedToGraph. Here Mem/NotLinkedNodesKept keeps its default
// (true), so the node reaches LTM either way and what changes is whether it stays
// part of the graph there (see DeleteLocationNotLinkedNodesKeptFalseDiscardsNode
// for what happens when that parameter is false):
// - true: the link-cleanup branch is skipped entirely, so peers keep their
// links to it and its weight/label survive. It remains a normal node.
// - false: (the default) it is unlinked from the graph before being saved --
// links removed on both sides, weight invalidated to -9, label
// cleared -- so it is kept as history only and no longer shows up in
// getAllSignatureIds().
// Both runs use the same 3-node chain A-B-C and remove B.
for(int pass = 0; pass < 2; ++pass)
{
const bool keepLinkedInDb = (pass == 0);
SCOPED_TRACE(uFormat("keepLinkedInDb=%s", keepLinkedInDb?"true":"false"));
const std::string dbPath = uniqueDbPath();
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(dbPath, true));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
for(int i = 0; i < 3; ++i)
{
SensorData data(image);
ASSERT_TRUE(memory.update(data, Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
}
const std::set<int> idsSet = memory.getAllSignatureIds();
ASSERT_EQ(idsSet.size(), 3u);
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int A = ids[0];
const int B = ids[1];
const int C = ids[2];
// Give B a distinctive weight/label so we can tell whether moveToTrash
// reset them (it sets weight=-9 and clears the label when unlinking).
{
Signature * bS = const_cast<Signature *>(memory.getSignature(B));
ASSERT_NE(bS, nullptr);
bS->setWeight(5);
}
ASSERT_TRUE(memory.labelSignature(B, "middle"));
ASSERT_TRUE(memory.getSignature(A)->hasLink(B));
ASSERT_TRUE(memory.getSignature(C)->hasLink(B));
memory.deleteLocation(B, 0, keepLinkedInDb);
memory.emptyTrash();
memory.joinTrashThread(); // emptyTrash() is async; wait for the DB write
// Either way B leaves WM/STM.
EXPECT_EQ(memory.getSignature(B), nullptr);
// Peers keep their links to B only when it stays linked in the database.
ASSERT_NE(memory.getSignature(A), nullptr);
ASSERT_NE(memory.getSignature(C), nullptr);
EXPECT_EQ(memory.getSignature(A)->hasLink(B), keepLinkedInDb);
EXPECT_EQ(memory.getSignature(C)->hasLink(B), keepLinkedInDb);
// Same on B's own side, read back from LTM.
const std::multimap<int, Link> bLinks = memory.getNeighborLinks(B, true);
EXPECT_EQ(!bLinks.empty(), keepLinkedInDb);
// Mem/NotLinkedNodesKept defaults to true, so the row is in the database in
// both cases -- but unlinking also invalidates its weight (-9) and drops its
// label, and getAllSignatureIds() filters invalid nodes out by default.
EXPECT_EQ(memory.getAllSignatureIds(/*ignoreChildren=*/false).count(B), 1u);
EXPECT_EQ(memory.getAllSignatureIds().count(B), keepLinkedInDb ? 1u : 0u);
Transform odomPose, groundTruth;
int mapId = 0, weight = 0;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
ASSERT_TRUE(memory.getNodeInfo(B, odomPose, mapId, weight, label, stamp,
groundTruth, velocity, gps, sensors, true));
EXPECT_EQ(weight, keepLinkedInDb ? 5 : -9);
EXPECT_EQ(label, keepLinkedInDb ? std::string("middle") : std::string());
memory.close(false);
UFile::erase(dbPath.c_str());
}
}
TEST(MemoryTest, DeleteLocationNotLinkedNodesKeptFalseDiscardsNode)
{
// Mem/NotLinkedNodesKept=false removes the fallback that keeps unlinked nodes in
// LTM, so moveToTrash's save condition
// (_notLinkedNodesKeptInDb || keepLinkedToGraph || s->isSaved())
// only holds when the node stays linked (or was already saved). Deleting a
// never-saved node with keepLinkedInDb=false therefore drops it outright -- it is
// not written to the database at all -- while keepLinkedInDb=true still saves it.
for(int pass = 0; pass < 2; ++pass)
{
const bool keepLinkedInDb = (pass == 0);
SCOPED_TRACE(uFormat("keepLinkedInDb=%s", keepLinkedInDb?"true":"false"));
const std::string dbPath = uniqueDbPath();
ParametersMap params = defaultMemoryParams();
uInsert(params, ParametersPair(Parameters::kMemNotLinkedNodesKept(), "false"));
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath, true));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
for(int i = 0; i < 3; ++i)
{
SensorData data(image);
ASSERT_TRUE(memory.update(data, Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
}
const std::set<int> idsSet = memory.getAllSignatureIds();
ASSERT_EQ(idsSet.size(), 3u);
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int B = ids[1];
// Still in STM, so never written to the database yet.
ASSERT_TRUE(memory.isInSTM(B));
memory.deleteLocation(B, 0, keepLinkedInDb);
memory.emptyTrash();
memory.joinTrashThread(); // emptyTrash() is async; wait for the DB write
EXPECT_EQ(memory.getSignature(B), nullptr);
// Unlinked and not kept -> no row at all; kept linked -> saved as usual.
EXPECT_EQ(memory.getAllSignatureIds(/*ignoreChildren=*/false).count(B),
keepLinkedInDb ? 1u : 0u);
Transform odomPose, groundTruth;
int mapId = 0, weight = 0;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
EXPECT_EQ(memory.getNodeInfo(B, odomPose, mapId, weight, label, stamp,
groundTruth, velocity, gps, sensors, true), keepLinkedInDb);
memory.close(false);
UFile::erase(dbPath.c_str());
}
}
TEST(MemoryTest, DeleteLocationAlreadySavedNodeIsWrittenBackUnlinked)
{
// Third term of moveToTrash's save condition: even with
// Mem/NotLinkedNodesKept=false and keepLinkedInDb=false, a node that was already
// pushed to the database (s->isSaved()) is written back rather than discarded --
// but unlinked, so the stored row keeps no links and an invalid weight.
const std::string dbPath = uniqueDbPath();
ParametersMap params = defaultMemoryParams();
uInsert(params, ParametersPair(Parameters::kMemNotLinkedNodesKept(), "false"));
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath, true));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
for(int i = 0; i < 3; ++i)
{
SensorData data(image);
ASSERT_TRUE(memory.update(data, Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
}
const std::set<int> idsSet = memory.getAllSignatureIds();
ASSERT_EQ(idsSet.size(), 3u);
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int A = ids[0];
const int B = ids[1];
const int C = ids[2];
// Flush B to the database so it is flagged as saved.
memory.saveLocationData(B);
memory.emptyTrash();
memory.joinTrashThread(); // emptyTrash() is async; wait for the DB write
ASSERT_TRUE(memory.getSignature(B)->isSaved());
memory.deleteLocation(B, 0, /*keepLinkedInDb=*/false);
memory.emptyTrash();
memory.joinTrashThread(); // emptyTrash() is async; wait for the DB write
EXPECT_EQ(memory.getSignature(B), nullptr);
// Kept in the database thanks to isSaved(), unlike the never-saved node of
// DeleteLocationNotLinkedNodesKeptFalseDiscardsNode.
EXPECT_EQ(memory.getAllSignatureIds(/*ignoreChildren=*/false).count(B), 1u);
// ... but stored unlinked: peers dropped their links and so did B.
EXPECT_FALSE(memory.getSignature(A)->hasLink(B));
EXPECT_FALSE(memory.getSignature(C)->hasLink(B));
EXPECT_TRUE(memory.getNeighborLinks(B, true).empty());
Transform odomPose, groundTruth;
int mapId = 0, weight = 0;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
ASSERT_TRUE(memory.getNodeInfo(B, odomPose, mapId, weight, label, stamp,
groundTruth, velocity, gps, sensors, true));
EXPECT_EQ(weight, -9);
EXPECT_EQ(memory.getAllSignatureIds().count(B), 0u);
memory.close(false);
UFile::erase(dbPath.c_str());
}
TEST_F(MemoryFixture, MoveToTrashTransfersGlobalLoopClosureWeight)
{
// When a removed signature has a kGlobalClosure link to a peer and a positive
// weight, moveToTrash copies that weight to the peer (kGlobalClosure branch in
// the link-cleanup loop of Memory::moveToTrash).
// Use a 3-node chain so the loop closure is added between non-adjacent nodes A
// and C (otherwise addLink early-returns because the endpoints are already linked
// by the kNeighbor edge from update()).
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
const std::set<int> idsSet = memory_->getAllSignatureIds();
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int A = ids[0];
const int C = ids[2];
{
Signature * cS = const_cast<Signature *>(memory_->getSignature(C));
ASSERT_NE(cS, nullptr);
cS->setWeight(5);
}
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory_->addLink(Link(C, A, Link::kGlobalClosure, Transform::getIdentity(), infMatrix)));
// After addLink with default kRGBDReduceGraph=false: max(A=1, C=3)=C keeps its
// weight; the other endpoint is zeroed (it was already 0 here).
EXPECT_EQ(memory_->getSignature(C)->getWeight(), 5);
EXPECT_EQ(memory_->getSignature(A)->getWeight(), 0);
memory_->deleteLocation(C);
// A absorbs C's weight via the kGlobalClosure branch in moveToTrash.
const Signature * aS = memory_->getSignature(A);
ASSERT_NE(aS, nullptr);
EXPECT_EQ(aS->getWeight(), 5);
}
TEST_F(MemoryFixture, MoveToTrashCleansLandmarkIndexWhenLastObserver)
{
// B is the only observer of a landmark. Removing B must drop the landmark id from
// _landmarksIndex entirely (the entry is erased once the observer set becomes empty).
// _landmarksIndex is populated by Memory::createSignature when SensorData carries
// a Landmarks observation, so we attach the landmark through update().
const int landmarkPositiveId = 7;
const int landmarkInternalId = -landmarkPositiveId; // Memory stores landmark ids as negative
const cv::Mat lmCov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
SensorData data(image_);
Landmarks landmarks;
landmarks.insert(std::make_pair(
landmarkPositiveId,
Landmark(landmarkPositiveId, 0.1f, Transform(1.0f, 0, 0, 0, 0, 0), lmCov)));
data.setLandmarks(landmarks);
Transform pose(0.0f, 0.0f, 0.0f, 0, 0, 0);
ASSERT_TRUE(memory_->update(data, pose, covariance_));
const int B = memory_->getLastSignatureId();
// Pre-state: index has the landmark with B as sole observer.
const std::map<int, std::set<int> > & idxBefore = memory_->getLandmarksIndex();
ASSERT_EQ(idxBefore.count(landmarkInternalId), 1u);
ASSERT_EQ(idxBefore.at(landmarkInternalId).size(), 1u);
ASSERT_EQ(idxBefore.at(landmarkInternalId).count(B), 1u);
memory_->deleteLocation(B);
// Post-state: the landmark id is gone from the index entirely (set became empty).
const std::map<int, std::set<int> > & idxAfter = memory_->getLandmarksIndex();
EXPECT_EQ(idxAfter.count(landmarkInternalId), 0u);
}
TEST_F(MemoryFixture, MoveToTrashPreservesLandmarkIndexWhenOtherObserverRemains)
{
// Two signatures observe the same landmark; only B is removed. The landmark id
// must stay in _landmarksIndex with C still listed in its observer set.
const int landmarkPositiveId = 8;
const int landmarkInternalId = -landmarkPositiveId;
const cv::Mat lmCov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
auto makeDataWithLandmark = [&](float poseX) {
SensorData data(image_);
Landmarks landmarks;
landmarks.insert(std::make_pair(
landmarkPositiveId,
Landmark(landmarkPositiveId, 0.1f, Transform(1.0f, 0, 0, 0, 0, 0), lmCov)));
data.setLandmarks(landmarks);
Transform pose(poseX, 0.0f, 0.0f, 0, 0, 0);
ASSERT_TRUE(memory_->update(data, pose, covariance_));
};
makeDataWithLandmark(0.0f);
const int B = memory_->getLastSignatureId();
makeDataWithLandmark(1.0f);
const int C = memory_->getLastSignatureId();
// Pre-state: index has the landmark with both B and C as observers.
const std::map<int, std::set<int> > & idxBefore = memory_->getLandmarksIndex();
ASSERT_EQ(idxBefore.count(landmarkInternalId), 1u);
ASSERT_EQ(idxBefore.at(landmarkInternalId).size(), 2u);
ASSERT_EQ(idxBefore.at(landmarkInternalId).count(B), 1u);
ASSERT_EQ(idxBefore.at(landmarkInternalId).count(C), 1u);
memory_->deleteLocation(B);
// Post-state: the landmark id is still in the index, but only C remains as observer.
const std::map<int, std::set<int> > & idxAfter = memory_->getLandmarksIndex();
ASSERT_EQ(idxAfter.count(landmarkInternalId), 1u);
EXPECT_EQ(idxAfter.at(landmarkInternalId).size(), 1u);
EXPECT_EQ(idxAfter.at(landmarkInternalId).count(C), 1u);
EXPECT_EQ(idxAfter.at(landmarkInternalId).count(B), 0u);
// C still carries the landmark observation on its own signature.
const Signature * cS = memory_->getSignature(C);
ASSERT_NE(cS, nullptr);
EXPECT_EQ(cS->getLandmarks().count(landmarkInternalId), 1u);
}
TEST_F(MemoryFixture, MoveToTrashReassignsLastWorkingSignature)
{
// After update(), _lastSignature points to the just-added node. moveToTrash on the
// last signature must reassign _lastSignature to the next-most-recent member of
// STM (or WM if STM is empty).
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
const int lastId = memory_->getLastSignatureId();
const std::set<int> idsSet = memory_->getAllSignatureIds();
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int secondLastId = ids[ids.size() - 2];
ASSERT_EQ(memory_->getLastWorkingSignature(false)->id(), lastId);
memory_->deleteLocation(lastId);
const Signature * newLast = memory_->getLastWorkingSignature(false);
ASSERT_NE(newLast, nullptr);
EXPECT_EQ(newLast->id(), secondLastId);
}
TEST_F(MemoryFixture, MoveToTrashResetsLastGlobalLoopClosureId)
{
// addLink(kGlobalClosure) sets _lastGlobalLoopClosureId to max(from, to). Removing
// that node must reset _lastGlobalLoopClosureId back to 0. Loop closure is added
// between non-adjacent nodes A and C to avoid addLink's early-return on already-linked
// endpoints.
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
const std::set<int> idsSet = memory_->getAllSignatureIds();
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int A = ids[0];
const int C = ids[2];
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory_->addLink(Link(A, C, Link::kGlobalClosure, Transform::getIdentity(), infMatrix)));
ASSERT_EQ(memory_->getLastGlobalLoopClosureId(), C);
memory_->deleteLocation(C);
EXPECT_EQ(memory_->getLastGlobalLoopClosureId(), 0);
}
TEST_F(MemoryFixture, MoveToTrashIsNoOpOnRepeatedDelete)
{
// deleteLocation on an id that was already deleted is silently a no-op. The
// underlying moveToTrash early-returns when its Signature* is null.
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
memory_->deleteLocation(id);
EXPECT_EQ(memory_->getSignature(id), nullptr);
// Second call should not crash.
memory_->deleteLocation(id);
EXPECT_EQ(memory_->getSignature(id), nullptr);
}
// ---------------------------------------------------------------------------
// reduceNode
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, ReduceNodeReturnsZeroForUnknownId)
{
// No matching signature in WM/STM.
EXPECT_EQ(memory_->reduceNode(99999), 0);
}
TEST_F(MemoryFixture, ReduceNodeReturnsZeroForLabeledNode)
{
// Labeled signatures are explicitly excluded from reduction.
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
ASSERT_TRUE(memory_->labelSignature(id, "anchor"));
EXPECT_EQ(memory_->reduceNode(id), 0);
// Signature must still exist.
EXPECT_NE(memory_->getSignature(id), nullptr);
}
TEST_F(MemoryFixture, ReduceNodeReturnsZeroWhenNoReducibleLink)
{
// canBeReduced rejects kNeighbor / kNeighborMerged links and self-loops, so a node
// with only neighbor links to its chain peers has nothing to reduce against.
for(int i = 0; i < 3; ++i)
{
ASSERT_TRUE(update());
}
const std::set<int> idsSet = memory_->getAllSignatureIds();
std::vector<int> ids(idsSet.begin(), idsSet.end());
const int idMiddle = ids[1];
EXPECT_EQ(memory_->reduceNode(idMiddle), 0);
EXPECT_NE(memory_->getSignature(idMiddle), nullptr);
}
TEST(MemoryTest, ReduceNodeMergesAndRemovesNodeViaLoopClosure)
{
// Build a chain A-B-C-D-E with all five nodes in WM (STM=1 + 6 updates so the
// first five ids are promoted to WM and only the last one stays in STM). Add a
// loop closure D->B and call reduceNode(D): it should pick the loop closure as
// the reduction target, redirect D's neighbor links to its non-already-linked
// peers as kNeighborMerged links on B, remove D from memory, and return B.
//
// Chain neighbor structure:
// A=1 -- B=2 -- C=3 -- D=4 -- E=5 (each pair connected by kNeighbor)
// Loop closure: D=4 -> B=2 (kGlobalClosure).
// After reduceNode(D):
// - D is removed from Memory.
// - B's existing neighbor link to C is untouched (no duplicate merge link added).
// - B gains a kNeighborMerged link to E (E was previously only linked to D).
ParametersMap params = defaultMemoryParams(/*stmSize=*/1);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 6; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
ASSERT_EQ(ids.size(), 6u);
const int A = ids[0];
const int B = ids[1];
const int C = ids[2];
const int D = ids[3];
const int E = ids[4];
// A, B, C, D, E must all be in WM (only the most recent id stays in STM=1).
ASSERT_TRUE(memory.isInWM(A));
ASSERT_TRUE(memory.isInWM(B));
ASSERT_TRUE(memory.isInWM(C));
ASSERT_TRUE(memory.isInWM(D));
ASSERT_TRUE(memory.isInWM(E));
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory.addLink(Link(D, B, Link::kGlobalClosure, Transform(2.0f, 0, 0, 0, 0, 0), infMatrix)));
const int reducedTo = memory.reduceNode(D);
EXPECT_EQ(reducedTo, B);
// D is removed from memory.
EXPECT_EQ(memory.getSignature(D), nullptr);
EXPECT_FALSE(memory.isInSTM(D));
EXPECT_FALSE(memory.isInWM(D));
// C, B, E are still around. Their previous links to D are gone.
const Signature * bS = memory.getSignature(B);
const Signature * cS = memory.getSignature(C);
const Signature * eS = memory.getSignature(E);
ASSERT_NE(bS, nullptr);
ASSERT_NE(cS, nullptr);
ASSERT_NE(eS, nullptr);
EXPECT_FALSE(bS->hasLink(D));
EXPECT_FALSE(cS->hasLink(D));
EXPECT_FALSE(eS->hasLink(D));
// B was already connected to C by a kNeighbor link in the chain, so no merge link
// between B and C is added (the merge step skips pairs already sharing a link).
EXPECT_TRUE(bS->hasLink(C, Link::kNeighbor));
// B and E were NOT directly linked before -- E was only connected via D. Reducing
// D merges D's neighbor link to E onto B as a kNeighborMerged link in both
// directions.
EXPECT_TRUE(bS->hasLink(E, Link::kNeighborMerged));
EXPECT_TRUE(eS->hasLink(B, Link::kNeighborMerged));
// reduceNode marks the memory + links as changed.
EXPECT_TRUE(memory.memoryChanged());
memory.close(false);
}
TEST(MemoryTest, ReduceNodeCreatesTwoMergedLinksWhenNeighborsAreUnlinkedToTarget)
{
// Extend the chain so there are two nodes between B and D:
// A=1 -- B=2 -- M1=3 -- M2=4 -- D=5 -- E=6
// All six are in WM (STM=1 + 7 updates puts the last id in STM, the first six in WM).
// Loop closure D=5 -> B=2. D's neighbors are M2 (key=4) and E (key=6). B is *not*
// directly linked to either, so reduceNode(D) must add TWO kNeighborMerged links:
// B <-> M2 and B <-> E.
// B's existing kNeighbor links to A and M1 are untouched.
ParametersMap params = defaultMemoryParams(/*stmSize=*/1);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 7; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
ASSERT_EQ(ids.size(), 7u);
const int A = ids[0];
const int B = ids[1];
const int M1 = ids[2];
const int M2 = ids[3];
const int D = ids[4];
const int E = ids[5];
ASSERT_TRUE(memory.isInWM(A));
ASSERT_TRUE(memory.isInWM(B));
ASSERT_TRUE(memory.isInWM(M1));
ASSERT_TRUE(memory.isInWM(M2));
ASSERT_TRUE(memory.isInWM(D));
ASSERT_TRUE(memory.isInWM(E));
// Sanity-check the pre-state: B is linked to A and M1 only; M2 and E are not yet
// linked to B (only via the chain through D).
{
const Signature * bS = memory.getSignature(B);
ASSERT_NE(bS, nullptr);
EXPECT_TRUE(bS->hasLink(A, Link::kNeighbor));
EXPECT_TRUE(bS->hasLink(M1, Link::kNeighbor));
EXPECT_FALSE(bS->hasLink(M2));
EXPECT_FALSE(bS->hasLink(E));
EXPECT_FALSE(bS->hasLink(D));
}
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory.addLink(Link(D, B, Link::kGlobalClosure, Transform(3.0f, 0, 0, 0, 0, 0), infMatrix)));
const int reducedTo = memory.reduceNode(D);
EXPECT_EQ(reducedTo, B);
// D is gone.
EXPECT_EQ(memory.getSignature(D), nullptr);
const Signature * bS = memory.getSignature(B);
const Signature * m2S = memory.getSignature(M2);
const Signature * eS = memory.getSignature(E);
ASSERT_NE(bS, nullptr);
ASSERT_NE(m2S, nullptr);
ASSERT_NE(eS, nullptr);
// Existing kNeighbor links on B (to A and M1) are untouched.
EXPECT_TRUE(bS->hasLink(A, Link::kNeighbor));
EXPECT_TRUE(bS->hasLink(M1, Link::kNeighbor));
// The two new merged links are present on B and mirrored on M2 / E.
EXPECT_TRUE(bS->hasLink(M2, Link::kNeighborMerged));
EXPECT_TRUE(bS->hasLink(E, Link::kNeighborMerged));
EXPECT_TRUE(m2S->hasLink(B, Link::kNeighborMerged));
EXPECT_TRUE(eS->hasLink(B, Link::kNeighborMerged));
// Everyone's link to D is gone.
EXPECT_FALSE(bS->hasLink(D));
EXPECT_FALSE(m2S->hasLink(D));
EXPECT_FALSE(eS->hasLink(D));
// M2 keeps its original kNeighbor link to M1 (untouched by the merge).
EXPECT_TRUE(m2S->hasLink(M1, Link::kNeighbor));
memory.close(false);
}
TEST(MemoryTest, ReduceNodePropagatesLandmarkObservationToTarget)
{
// Chain A=1 -- B=2 -- C=3 -- D=4 -- E=5 -- F=6 all in WM (STM=1 + 7 updates).
// Poses (world): each node sits at x = its position in the chain. So in particular
// B=2 is at world x=1.0 and D=4 is at world x=3.0.
// We attach a landmark observation L to D with T_DL = (2, 0, 0) -- i.e. L is 2m
// ahead of D in D's local frame, which puts L at world x=5. Then we add a loop
// closure D->B with relative transform T_DB = (-2, 0, 0) (B is 2m behind D).
// reduceNode(D) must propagate the landmark observation from D onto B. The new
// link on B has transform T_BL = inv(T_DB) * T_DL = (2, 0, 0) * (2, 0, 0) = (4, 0, 0),
// which matches the relative position of L in B's frame: world_L - world_B = 4.
ParametersMap params = defaultMemoryParams(/*stmSize=*/1);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 7; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
const int B = ids[1];
const int D = ids[3];
ASSERT_TRUE(memory.isInWM(B));
ASSERT_TRUE(memory.isInWM(D));
// Attach a landmark observation on D directly via the underlying Signature.
// Landmark ids are negative by convention (see Link::kLandmark contract).
const int landmarkId = -42;
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
const Transform T_DL(2.0f, 0, 0, 0, 0, 0);
{
Signature * dS = const_cast<Signature *>(memory.getSignature(D));
ASSERT_NE(dS, nullptr);
dS->addLandmark(Link(D, landmarkId, Link::kLandmark, T_DL, infMatrix));
}
// Loop closure D->B with relative transform T_DB = (-2, 0, 0).
const Transform T_DB(-2.0f, 0, 0, 0, 0, 0);
ASSERT_TRUE(memory.addLink(Link(D, B, Link::kGlobalClosure, T_DB, infMatrix)));
// Pre-state: B has no landmark observation.
{
const Signature * bS = memory.getSignature(B);
ASSERT_NE(bS, nullptr);
EXPECT_EQ(bS->getLandmarks().count(landmarkId), 0u);
}
const int reducedTo = memory.reduceNode(D);
EXPECT_EQ(reducedTo, B);
// D is removed, landmark observation is now on B.
EXPECT_EQ(memory.getSignature(D), nullptr);
const Signature * bS = memory.getSignature(B);
ASSERT_NE(bS, nullptr);
ASSERT_EQ(bS->getLandmarks().count(landmarkId), 1u);
const Link & bLink = bS->getLandmarks().at(landmarkId);
EXPECT_EQ(bLink.from(), B);
EXPECT_EQ(bLink.to(), landmarkId);
EXPECT_EQ(bLink.type(), Link::kLandmark);
// Propagated transform: inv(T_DB) * T_DL = (2, 0, 0) * (2, 0, 0) = (4, 0, 0).
// This matches L's position relative to B (world_L=5 minus world_B=1 = 4).
EXPECT_NEAR(bLink.transform().x(), 4.0f, 1e-4f);
EXPECT_NEAR(bLink.transform().y(), 0.0f, 1e-4f);
EXPECT_NEAR(bLink.transform().z(), 0.0f, 1e-4f);
// _landmarksIndex now references B for this landmark id.
const std::map<int, std::set<int> > & idx = memory.getLandmarksIndex();
const auto it = idx.find(landmarkId);
ASSERT_NE(it, idx.end());
EXPECT_EQ(it->second.count(B), 1u);
// D is no longer in the index for this landmark (moveToTrash cleanup).
EXPECT_EQ(it->second.count(D), 0u);
// Landmark propagation only flows along non-neighbor links of the reduced node:
// the landmark branch in reduceNode is nested under the
// `iter->second.type() != Link::kNeighbor && iter->second.type() != Link::kUndef`
// guard, so D's direct kNeighbor peers (C and E) do NOT inherit the observation
// and the landmark index does not reference them either.
const int C = ids[2];
const int E = ids[4];
const Signature * cS = memory.getSignature(C);
const Signature * eS = memory.getSignature(E);
ASSERT_NE(cS, nullptr);
ASSERT_NE(eS, nullptr);
EXPECT_EQ(cS->getLandmarks().count(landmarkId), 0u);
EXPECT_EQ(eS->getLandmarks().count(landmarkId), 0u);
EXPECT_EQ(it->second.count(C), 0u);
EXPECT_EQ(it->second.count(E), 0u);
memory.close(false);
}
TEST(MemoryTest, ReduceNodeRespectsDirection)
{
// Same WM setup as ReduceNodeMergesAndRemovesNodeViaLoopClosure (STM=1 + 6 updates,
// chain A-B-C-D-E in WM). Loop closure D->B has to(B=2) < from(D=4):
// - direction=1 requires to > from -> rejected.
// - direction=-1 requires to < from -> accepted.
ParametersMap params = defaultMemoryParams(/*stmSize=*/1);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 6; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
const int B = ids[1];
const int D = ids[3];
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory.addLink(Link(D, B, Link::kGlobalClosure, Transform(2.0f, 0, 0, 0, 0, 0), infMatrix)));
// direction = 1: D->B has to(B=2) < from(D=4), so not accepted.
EXPECT_EQ(memory.reduceNode(D, /*maxDistance=*/0.0f, /*keepLinkedInDb=*/false, /*direction=*/1), 0);
EXPECT_NE(memory.getSignature(D), nullptr);
// direction = -1: 2 < 4, accepted.
EXPECT_EQ(memory.reduceNode(D, /*maxDistance=*/0.0f, /*keepLinkedInDb=*/false, /*direction=*/-1), B);
EXPECT_EQ(memory.getSignature(D), nullptr);
memory.close(false);
}
TEST(MemoryTest, ReduceNodeRespectsMaxDistance)
{
// Same WM setup. Loop closure D->B with transform norm 5.0. canBeReduced rejects
// the link when maxDistance=1.0 (link.transform().getNorm() not < 1.0) and accepts
// it at maxDistance=10.0.
ParametersMap params = defaultMemoryParams(/*stmSize=*/1);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 6; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
const int B = ids[1];
const int D = ids[3];
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
ASSERT_TRUE(memory.addLink(Link(D, B, Link::kGlobalClosure, Transform(5.0f, 0, 0, 0, 0, 0), infMatrix)));
EXPECT_EQ(memory.reduceNode(D, /*maxDistance=*/1.0f), 0);
EXPECT_NE(memory.getSignature(D), nullptr);
EXPECT_EQ(memory.reduceNode(D, /*maxDistance=*/10.0f), B);
EXPECT_EQ(memory.getSignature(D), nullptr);
memory.close(false);
}
// ---------------------------------------------------------------------------
// Map id
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, IncrementMapIdReturnsNewMapId)
{
ASSERT_TRUE(update());
const int firstMap = memory_->getMapId(memory_->getLastSignatureId());
EXPECT_EQ(firstMap, 0); // first map id is 0
const int newMap = memory_->incrementMapId();
EXPECT_EQ(newMap, firstMap + 1);
ASSERT_TRUE(update());
const int secondMap = memory_->getMapId(memory_->getLastSignatureId());
EXPECT_EQ(secondMap, newMap);
}
// ---------------------------------------------------------------------------
// Forget / reactivate (require a database)
// ---------------------------------------------------------------------------
TEST(MemoryTest, ForgetTransfersOldestWmSignaturesToLtm)
{
// forget() has two branches:
// 1) When the visual word dictionary is non-empty and FLANN is not incremental,
// it transfers signatures until at least _vwd->getNotIndexedWordsCount() words
// are unused.
// 2) Otherwise (e.g. dictionary empty because no features were extracted, or
// _vwd->isIncrementalFlann() is true, or the memory is not incremental), it
// transfers _signaturesAdded+1 signatures from the working memory regardless
// of words.
// This test exercises branch (2): kKpMaxFeatures=-1 -> no words at all, so forget()
// still works and transfers the oldest WM signatures.
ParametersMap params = defaultMemoryParams(2); // STM=2 so the chain quickly fills WM
Memory memory(params);
ASSERT_TRUE(memory.init("")); // in-memory database so moveToTrash() has a destination
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
// With STM=2 and 5 updates, STM holds the 2 newest ids (ids[3], ids[4]) and WM
// holds the 3 oldest (ids[0], ids[1], ids[2]) plus the virtual place entry that
// init() pushes in (kIdVirtual). ids[2] has a neighbor link to ids[3] (which is
// in STM), so getRemovableSignatures() excludes it as a rehearsal safeguard.
// That leaves ids[0] and ids[1] as the only removable nodes.
ASSERT_EQ((int)memory.getStMem().size(), 2);
ASSERT_EQ((int)memory.getWorkingMem().size(), 4); // 3 real + 1 virtual
ASSERT_TRUE(memory.isInWM(ids[0]));
ASSERT_TRUE(memory.isInWM(ids[1]));
ASSERT_TRUE(memory.isInWM(ids[2]));
std::list<int> transferred = memory.forget(std::set<int>());
// _signaturesAdded is reset by preUpdate() and incremented in addSignatureToStm,
// so after a single iteration its value is 1; forget() in branch (2) tries to
// remove _signaturesAdded+1 = 2 signatures and both ids[0] and ids[1] are
// removable, so they should both be transferred.
ASSERT_EQ((int)transferred.size(), 2);
std::set<int> transferredSet(transferred.begin(), transferred.end());
EXPECT_TRUE(transferredSet.count(ids[0]) > 0);
EXPECT_TRUE(transferredSet.count(ids[1]) > 0);
// ids[0] and ids[1] are no longer loaded in WM/STM. They are now in LTM
// (persisted by the async db driver).
EXPECT_FALSE(memory.isInWM(ids[0]));
EXPECT_FALSE(memory.isInWM(ids[1]));
EXPECT_TRUE(memory.isInLTM(ids[0]));
EXPECT_TRUE(memory.isInLTM(ids[1]));
// ids[2] could not be transferred because it is linked to an STM node, so it
// is still in WM.
EXPECT_TRUE(memory.isInWM(ids[2]));
// STM is untouched.
EXPECT_TRUE(memory.isInSTM(ids[3]));
EXPECT_TRUE(memory.isInSTM(ids[4]));
memory.close(false);
}
namespace {
// Populates dbPath with a few signatures carrying pre-baked visual words, so a
// later Memory::init() has a real dictionary to load. Uses the "odom features"
// path (kMemUseOdomFeatures) to avoid running real feature extraction.
ParametersMap dictionaryDbParams()
{
ParametersMap params = defaultMemoryParams(5);
params[Parameters::kKpMaxFeatures()] = "10";
params[Parameters::kKpIncrementalFlann()] = "false";
params[Parameters::kMemUseOdomFeatures()] = "true";
return params;
}
int buildDictionaryDb(const std::string & dbPath)
{
const int kKeypointsPerFrame = 3;
const int kFrames = 3;
Memory memory(dictionaryDbParams());
if(!memory.init(dbPath, true))
{
return 0;
}
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
for(int frame = 0; frame < kFrames; ++frame)
{
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
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);
if(!memory.update(data, Transform(float(frame), 0.0f, 0.0f, 0, 0, 0), covariance))
{
return 0;
}
}
const int wordCount = (int)memory.getVWDictionary()->getVisualWords().size();
memory.close(true); // save dictionary + signatures to the database
return wordCount;
}
} // namespace
TEST(MemoryTest, SetDummyDictionaryLoadsWordIdsWithoutDescriptors)
{
// With a dummy dictionary, init() fills VWDictionary with placeholder words
// (same ids, empty descriptors) and skips the dictionary update, so a large
// map can be opened without paying for the descriptors or the FLANN index.
const std::string dbPath = uniqueDbPath();
const int wordCount = buildDictionaryDb(dbPath);
ASSERT_GT(wordCount, 0);
// Reference: normal load brings the descriptors back.
std::set<int> fullIds;
{
Memory memory(dictionaryDbParams());
ASSERT_TRUE(memory.init(dbPath));
const std::map<int, VisualWord *> & words = memory.getVWDictionary()->getVisualWords();
ASSERT_EQ((int)words.size(), wordCount);
for(std::map<int, VisualWord *>::const_iterator iter=words.begin(); iter!=words.end(); ++iter)
{
fullIds.insert(iter->first);
EXPECT_FALSE(iter->second->getDescriptor().empty());
}
memory.close(false);
}
// Dummy dictionary: same word ids, no descriptors.
{
Memory memory(dictionaryDbParams());
memory.setDummyDictionary(true);
ASSERT_TRUE(memory.init(dbPath));
const std::map<int, VisualWord *> & words = memory.getVWDictionary()->getVisualWords();
std::set<int> dummyIds;
for(std::map<int, VisualWord *>::const_iterator iter=words.begin(); iter!=words.end(); ++iter)
{
dummyIds.insert(iter->first);
EXPECT_TRUE(iter->second->getDescriptor().empty());
}
EXPECT_EQ(dummyIds, fullIds);
memory.close(false);
}
UFile::erase(dbPath.c_str());
}
TEST(MemoryTest, SetDummyDictionaryDisablesItselfWhenDatabaseHasNoWords)
{
// The dummy dictionary only makes sense when there is a dictionary to skip
// loading. When the loaded nodes reference no word at all, init() silently
// clears the flag, so the memory keeps behaving like a normal (mapping-capable)
// one instead of staying in the read-only dummy state.
// Checked on both dictionary-loading paths: Mem/InitWMWithAllNodes=true walks
// the words referenced by the loaded nodes, false loads the stored dictionary
// wholesale through DBDriver::load().
const std::string dbPath = uniqueDbPath();
{
// defaultMemoryParams disables feature extraction, so these nodes carry no
// visual words and the Word table stays empty.
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(dbPath, true));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
for(int i = 0; i < 2; ++i)
{
SensorData data(image);
ASSERT_TRUE(memory.update(data, Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
}
ASSERT_TRUE(memory.getVWDictionary()->getVisualWords().empty());
memory.close(true);
}
for(int pass = 0; pass < 2; ++pass)
{
const bool initWMWithAllNodes = (pass == 0);
SCOPED_TRACE(uFormat("Mem/InitWMWithAllNodes=%s", initWMWithAllNodes?"true":"false"));
ParametersMap params = dictionaryDbParams();
params[Parameters::kMemInitWMWithAllNodes()] = initWMWithAllNodes?"true":"false";
Memory memory(params);
memory.setDummyDictionary(true);
ASSERT_TRUE(memory.init(dbPath));
EXPECT_TRUE(memory.getVWDictionary()->getVisualWords().empty());
// The flag was cleared, so adding a node still works and populates the
// dictionary normally (a memory left in dummy mode cannot create signatures).
const int kKeypoints = 3;
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
std::vector<cv::KeyPoint> kpts(kKeypoints, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(kKeypoints, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::zeros(kKeypoints, kKeypoints, CV_32F);
for(int row = 0; row < kKeypoints; ++row)
{
descriptors.at<float>(row, row) = 1000.0f;
}
data.setFeatures(kpts, pts3, descriptors);
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(data, Transform(5.0f, 0.0f, 0.0f, 0, 0, 0), covariance));
EXPECT_EQ((int)memory.getVWDictionary()->getVisualWords().size(), kKeypoints);
memory.close(false); // don't persist the extra node: next pass reuses the db
}
UFile::erase(dbPath.c_str());
}
TEST(MemoryTest, SetDummyDictionaryDisabledWhenDictionaryMustBeRepaired)
{
// When the stored dictionary is missing words that loaded nodes still reference,
// init() rebuilds it from the nodes' descriptors. Placeholder words carry no
// descriptor, so the dummy dictionary cannot be used for that: it is disabled
// first and the repaired dictionary comes back with real descriptors.
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
// Corrupt the dictionary: drop *some* of the stored words while the nodes keep
// referencing them (what happens when rtabmap is killed before saving it).
// Only a subset, so the dictionary still loads non-empty and it is really the
// repair branch -- not the empty-dictionary check -- that disables the flag.
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
driver->executeNoResult("DELETE FROM Word WHERE id IN (SELECT id FROM Word LIMIT 2);");
driver->closeConnection(false);
delete driver;
}
Memory memory(dictionaryDbParams());
memory.setDummyDictionary(true);
ASSERT_TRUE(memory.init(dbPath));
// Repaired from the nodes: words are back, with real descriptors rather than
// the empty placeholders a dummy dictionary would have produced.
const std::map<int, VisualWord *> & words = memory.getVWDictionary()->getVisualWords();
ASSERT_FALSE(words.empty());
for(std::map<int, VisualWord *>::const_iterator iter=words.begin(); iter!=words.end(); ++iter)
{
EXPECT_FALSE(iter->second->getDescriptor().empty());
}
// The flag was cleared, so the memory can still add nodes.
const int kKeypoints = 3;
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
std::vector<cv::KeyPoint> kpts(kKeypoints, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(kKeypoints, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::zeros(kKeypoints, 9, CV_32F);
for(int row = 0; row < kKeypoints; ++row)
{
descriptors.at<float>(row, row) = 1000.0f;
}
data.setFeatures(kpts, pts3, descriptors);
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
EXPECT_TRUE(memory.update(data, Transform(9.0f, 0.0f, 0.0f, 0, 0, 0), covariance));
memory.close(false);
UFile::erase(dbPath.c_str());
}
TEST(MemoryTest, SetDummyDictionaryIgnoredAfterInit)
{
// The flag is only read while loading the dictionary, so setting it once the
// database driver exists is refused (an error is logged) and the already
// loaded descriptors stay in place.
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
Memory memory(dictionaryDbParams());
ASSERT_TRUE(memory.init(dbPath));
ASSERT_FALSE(memory.getVWDictionary()->getVisualWords().empty());
memory.setDummyDictionary(true); // too late
const std::map<int, VisualWord *> & words = memory.getVWDictionary()->getVisualWords();
for(std::map<int, VisualWord *>::const_iterator iter=words.begin(); iter!=words.end(); ++iter)
{
EXPECT_FALSE(iter->second->getDescriptor().empty());
}
memory.close(false);
UFile::erase(dbPath.c_str());
}
namespace {
// Localization session (Mem/IncrementalMemory=false) with FLANN index
// persistence enabled: the only configuration where Memory::saveFlannIndex()
// writes an index (see Kp/FlannIndexSaved). Incremental FLANN is left on --
// dictionaryDbParams() disables it -- because that is the path where newly
// indexed words are appended to the existing index instead of rebuilding it.
ParametersMap localizationFlannParams()
{
ParametersMap params = dictionaryDbParams();
params[Parameters::kMemIncrementalMemory()] = "false";
params[Parameters::kKpFlannIndexSaved()] = "true";
params[Parameters::kKpIncrementalFlann()] = "true";
return params;
}
// Whether init() could reuse the index stored in the database. VWDictionary
// only clears its "modified" flag when deserializeIndex() succeeds; an index
// (re)built by update() leaves it set, which is also what makes Memory save it
// back on close.
bool flannIndexReusedFromDb(const Memory & memory)
{
return !memory.getVWDictionary()->isModified();
}
} // namespace
TEST(MemoryTest, FlannIndexSavedToDatabaseOnlyWhenDatabaseIsSaved)
{
#ifdef _WIN32
GTEST_SKIP() << "FlannIndex serialization is not implemented on Windows";
#else
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
// First localization session: nothing stored yet, so the index is built by
// VWDictionary::update(). Discarding the session must not store it either.
{
Memory memory(localizationFlannParams());
ASSERT_TRUE(memory.init(dbPath));
ASSERT_FALSE(memory.getVWDictionary()->getVisualWords().empty());
ASSERT_FALSE(flannIndexReusedFromDb(memory));
memory.close(false);
}
// Second session: still no index in the database, this time save it.
{
Memory memory(localizationFlannParams());
ASSERT_TRUE(memory.init(dbPath));
EXPECT_FALSE(flannIndexReusedFromDb(memory)) << "close(false) should not have saved a FLANN index";
memory.close(true);
}
// Sessions 3, 4 and 5: the index stored by session 2 is reused on every
// re-open, and stays in the database whatever close() is given:
// pass 0, close(false): the discarded session leaves the stored index alone,
// pass 1, close(true) : the dictionary didn't change since it was loaded, so
// the re-save is skipped -- without clearing it,
// pass 2 : final check that the two closes above kept it.
for(int pass = 0; pass < 3; ++pass)
{
SCOPED_TRACE(uFormat("pass %d", pass));
Memory memory(localizationFlannParams());
ASSERT_TRUE(memory.init(dbPath));
const VWDictionary * dictionary = memory.getVWDictionary();
EXPECT_TRUE(flannIndexReusedFromDb(memory)) << "the stored FLANN index should have been reused";
EXPECT_EQ((size_t)dictionary->getIndexedWordsCount(), dictionary->getVisualWords().size());
memory.close(pass == 1);
}
UFile::erase(dbPath.c_str());
#endif
}
TEST(MemoryTest, RepairedDictionaryIndexIsReusableOnNextLoad)
{
#ifdef _WIN32
GTEST_SKIP() << "FlannIndex serialization is not implemented on Windows";
#else
// A repaired dictionary must be re-indexed from scratch, not have its
// recovered words appended to the index already built from the words that
// were still in the database. The next load rebuilds the search data in
// word-id order, so an index holding the recovered words at the end no
// longer matches it and is rejected (see VWDictionary::rebuildIndex()).
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
// Drop the lowest word ids while the nodes keep referencing them (what
// happens when rtabmap is killed before it saves the dictionary), so the
// recovered words are exactly the ones that would end up out of order.
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
driver->executeNoResult("DELETE FROM Word WHERE id IN (SELECT id FROM Word ORDER BY id ASC LIMIT 2);");
driver->closeConnection(false);
delete driver;
}
size_t wordCount = 0;
{
Memory memory(localizationFlannParams());
ASSERT_TRUE(memory.init(dbPath));
const VWDictionary * dictionary = memory.getVWDictionary();
wordCount = dictionary->getVisualWords().size();
ASSERT_GT(wordCount, 0u);
// Repaired: every word is indexed again, including the recovered ones.
EXPECT_EQ((size_t)dictionary->getIndexedWordsCount(), wordCount);
EXPECT_EQ(dictionary->getNotIndexedWordsCount(), 0u);
// Saves the recovered words back, along with the rebuilt index.
memory.close(true);
}
// Two re-opens: the first checks the index saved right after the repair is
// reusable, the second that it stays so once a session that merely loaded it
// has closed (nothing changed, so close(true) must leave it in place).
for(int pass = 0; pass < 2; ++pass)
{
SCOPED_TRACE(uFormat("pass %d", pass));
Memory memory(localizationFlannParams());
ASSERT_TRUE(memory.init(dbPath));
const VWDictionary * dictionary = memory.getVWDictionary();
EXPECT_EQ(dictionary->getVisualWords().size(), wordCount) << "the recovered words should have been saved back";
EXPECT_TRUE(flannIndexReusedFromDb(memory)) << "the index saved after the repair should still match the stored dictionary";
EXPECT_EQ((size_t)dictionary->getIndexedWordsCount(), wordCount);
memory.close(pass == 0);
}
UFile::erase(dbPath.c_str());
#endif
}
TEST(MemoryTest, ReadOnlyLocalizationSavesNothingBackAfterRepair)
{
// Mem/LocalizationReadOnly keeps close() from writing anything back, even
// when the session did change the memory. Repairing the dictionary is such a
// change (it sets _memoryChanged so the recovered words get saved), so a
// read-only session is where close() has to warn and skip the save instead.
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
// Remember which words are about to be dropped, to check further down that
// they did not come back.
std::set<int> droppedIds;
{
Memory memory(dictionaryDbParams());
ASSERT_TRUE(memory.init(dbPath));
const std::map<int, VisualWord *> & words = memory.getVWDictionary()->getVisualWords();
ASSERT_GE(words.size(), 2u);
std::map<int, VisualWord *>::const_iterator iter = words.begin();
droppedIds.insert(iter->first);
droppedIds.insert((++iter)->first);
memory.close(false);
}
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
driver->executeNoResult("DELETE FROM Word WHERE id IN (SELECT id FROM Word ORDER BY id ASC LIMIT 2);");
driver->closeConnection(false);
delete driver;
}
ParametersMap params = localizationFlannParams();
params[Parameters::kMemLocalizationReadOnly()] = "true";
size_t wordCount = 0;
{
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath));
ASSERT_TRUE(memory.isReadOnly());
wordCount = memory.getVWDictionary()->getVisualWords().size();
ASSERT_GT(wordCount, 0u);
// The repair happened in memory, and asks for the dictionary to be saved...
EXPECT_TRUE(memory.memoryChanged());
memory.close(true); // ...which a read-only memory refuses to do.
}
// The recovered words never reached the database.
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
std::list<VisualWord *> words;
driver->loadWords(droppedIds, words);
EXPECT_TRUE(words.empty()) << "a read-only memory should not have saved the repaired dictionary";
for(std::list<VisualWord *>::iterator iter=words.begin(); iter!=words.end(); ++iter)
{
delete *iter;
}
driver->closeConnection(false);
delete driver;
}
// Neither did the index rebuilt for them, so the next session repairs again.
{
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath));
EXPECT_EQ(memory.getVWDictionary()->getVisualWords().size(), wordCount);
EXPECT_FALSE(flannIndexReusedFromDb(memory));
memory.close(false);
}
UFile::erase(dbPath.c_str());
}
TEST(MemoryTest, DiscardedSessionSavesNothingBackWhenMemoryChanged)
{
// The other side of the same branch: a writable memory that did change, but
// closed with databaseSaved=false. The changes are dropped (with a warning)
// rather than written, so the database keeps the nodes it already had.
const std::string dbPath = uniqueDbPath();
ASSERT_GT(buildDictionaryDb(dbPath), 0);
std::set<int> idsBefore;
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
driver->getAllNodeIds(idsBefore);
driver->closeConnection(false);
delete driver;
}
ASSERT_FALSE(idsBefore.empty());
{
Memory memory(dictionaryDbParams());
ASSERT_TRUE(memory.init(dbPath));
ASSERT_FALSE(memory.isReadOnly());
ASSERT_FALSE(memory.memoryChanged());
const int kKeypoints = 3;
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
std::vector<cv::KeyPoint> kpts(kKeypoints, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> pts3(kKeypoints, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::zeros(kKeypoints, 9, CV_32F);
for(int row = 0; row < kKeypoints; ++row)
{
descriptors.at<float>(row, row) = 1000.0f;
}
data.setFeatures(kpts, pts3, descriptors);
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(data, Transform(9.0f, 0.0f, 0.0f, 0, 0, 0), covariance));
ASSERT_TRUE(memory.memoryChanged());
memory.close(false);
}
std::set<int> idsAfter;
{
DBDriver * driver = DBDriver::create();
ASSERT_NE(driver, nullptr);
ASSERT_TRUE(driver->openConnection(dbPath, false));
driver->getAllNodeIds(idsAfter);
driver->closeConnection(false);
delete driver;
}
EXPECT_EQ(idsAfter, idsBefore) << "close(false) should not have saved the node added during the session";
UFile::erase(dbPath.c_str());
}
Adding doc and tests (#1492) * 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)
2026-08-06 13:32:20 -07:00
TEST(MemoryTest, ForgetTransfersBasedOnWordCountInWordRegime)
{
// Branch (1) of Memory::forget() is gated on:
// isIncremental && _vwd->isIncremental && _vwd->getVisualWords().size() && !_vwd->isIncrementalFlann()
// To populate the dictionary without running real feature extraction, we set
// kMemUseOdomFeatures=true and feed pre-baked keypoints+descriptors as part of
// each SensorData (the "odom features" path in Memory::createSignature). We also
// disable incremental FLANN so the dictionary indexes words immediately and branch
// (1) is selected.
ParametersMap params = defaultMemoryParams(2); // STM=2
params[Parameters::kKpMaxFeatures()] = "10";
params[Parameters::kKpIncrementalFlann()] = "false";
params[Parameters::kMemUseOdomFeatures()] = "true";
Memory memory(params);
ASSERT_TRUE(memory.init(""));
const int kKeypointsPerFrame = 5;
const int kFrames = 5;
// One extra axis reserved for an additional single-keypoint signature we add after
// forget() to trigger cleanUnusedWords() (see end of the test).
const int kDescCols = kFrames * kKeypointsPerFrame + 1;
auto makeData = [&](int frameIndex) {
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
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));
// Each (frame, row) gets its own one-hot axis in a kFrames*kKeypointsPerFrame
// dimensional space, so every keypoint in every frame quantizes to its own
// brand new visual word. This guarantees notIndexedWordsCount > 0 at the time
// we call forget(), and keeps the dictionary growing on every iteration.
cv::Mat descriptors = cv::Mat::zeros(kKeypointsPerFrame, kDescCols, CV_32F);
for(int row = 0; row < kKeypointsPerFrame; ++row)
{
const int axis = frameIndex * kKeypointsPerFrame + row;
descriptors.at<float>(row, axis) = 1000.0f;
}
data.setFeatures(kpts, pts3, descriptors);
return data;
};
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
std::vector<int> ids;
for(int i = 0; i < kFrames; ++i)
{
ASSERT_TRUE(memory.update(makeData(i), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
// Every (frame, keypoint) generates a brand-new visual word, so after kFrames
// updates the dictionary holds exactly kFrames * kKeypointsPerFrame words and the
// last iteration left kKeypointsPerFrame of them pending indexing -- that's the
// quantity the word-count regime tries to "catch up" with.
ASSERT_EQ((int)memory.getVWDictionary()->getVisualWords().size(),
kFrames * kKeypointsPerFrame);
ASSERT_EQ(memory.getVWDictionary()->getNotIndexedWordsCount(), kKeypointsPerFrame);
ASSERT_EQ((int)memory.getStMem().size(), 2);
ASSERT_TRUE(memory.isInWM(ids[0]));
ASSERT_TRUE(memory.isInWM(ids[1]));
ASSERT_TRUE(memory.isInWM(ids[2]));
const size_t wordsBefore = memory.getVWDictionary()->getVisualWords().size();
std::list<int> transferred = memory.forget(std::set<int>());
// Branch (1) keeps transferring until wordsRemoved >= notIndexedBefore. Each
// transferred signature releases its kKeypointsPerFrame unique words (their
// reference count drops to zero), which exactly equals notIndexedBefore -- so
// the loop transfers exactly one signature.
ASSERT_EQ((int)transferred.size(), 1);
const int transferredId = transferred.front();
// ids[2] is still pinned by its neighbor link to STM (ids[3]); only ids[0] and
// ids[1] are candidates and getRemovableSignatures returns the oldest first.
EXPECT_EQ(transferredId, ids[0]);
EXPECT_FALSE(memory.isInWM(transferredId));
EXPECT_TRUE(memory.isInLTM(transferredId));
// The other removable id stays in WM since the word budget was already met.
EXPECT_TRUE(memory.isInWM(ids[1]));
// ids[2] is linked to STM and never removable.
EXPECT_TRUE(memory.isInWM(ids[2]));
// STM is untouched.
EXPECT_TRUE(memory.isInSTM(ids[3]));
EXPECT_TRUE(memory.isInSTM(ids[4]));
// The transferred signature released its word references but the words themselves
// are not physically removed until the next preUpdate's cleanUnusedWords(); the
// dictionary size is therefore unchanged at this point.
EXPECT_EQ(memory.getVWDictionary()->getVisualWords().size(), wordsBefore);
// Trigger cleanUnusedWords by running another update(): preUpdate() will physically
// drop the kKeypointsPerFrame unused words, then addNewWords() will add 1 new word
// for the single keypoint we provide on a fresh axis. Net change in dictionary size:
// -kKeypointsPerFrame + 1 = -4 words.
{
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData extraData(image);
std::vector<cv::KeyPoint> oneKpt(1, cv::KeyPoint(1.f, 1.f, 1.f));
std::vector<cv::Point3f> onePt3(1, cv::Point3f(0.f, 0.f, 1.f));
cv::Mat descriptors = cv::Mat::zeros(1, kDescCols, CV_32F);
// Use the reserved axis (kFrames * kKeypointsPerFrame) so the descriptor is
// orthogonal to every existing word and creates a brand-new visual word.
descriptors.at<float>(0, kFrames * kKeypointsPerFrame) = 1000.0f;
extraData.setFeatures(oneKpt, onePt3, descriptors);
ASSERT_TRUE(memory.update(extraData, Transform(float(kFrames), 0.0f, 0.0f, 0, 0, 0), covariance));
}
// The 5 unused words from the transferred signature are gone (cleanUnusedWords) and
// one new word was added for the new keypoint: 25 - 5 + 1 = 21.
EXPECT_EQ((int)memory.getVWDictionary()->getVisualWords().size(),
kFrames * kKeypointsPerFrame - kKeypointsPerFrame + 1);
EXPECT_EQ((int)memory.getVWDictionary()->getVisualWords().size(),
(int)wordsBefore - 4);
memory.close(false);
}
TEST(MemoryTest, ForgetExcludesWmNodesLinkedToStmTwoVariants)
{
// getRemovableSignatures rejects a WM candidate via two distinct gates:
// (A) lastInSTM->hasLink(memIter->first) -- the *oldest* STM node has a
// link pointing to the candidate.
// (B) iterating the candidate's own links and finding any endpoint in STM.
// With STM=2 and 5 updates, STM={ids[3], ids[4]}, lastInSTM = ids[3] (smallest STM
// id), and the WM candidates are ids[0], ids[1], ids[2]. ids[2] is *already*
// rejected by gate (A) via its kNeighbor link to ids[3] -- so the variants below
// only swap the gate that excludes ids[1] vs. ids[0].
const cv::Mat infMatrix = cv::Mat::eye(6, 6, CV_64FC1);
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
auto buildChain = [&](Memory & memory, std::vector<int> & ids) {
ids.clear();
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
};
// --- Variant A: loop closure FROM lastInSTM (oldest STM) TO a WM candidate.
// The lastInSTM->hasLink gate fires on ids[1].
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
std::vector<int> ids;
buildChain(memory, ids);
ASSERT_TRUE(memory.addLink(Link(ids[3], ids[1], Link::kGlobalClosure, Transform::getIdentity(), infMatrix)));
{
std::list<int> transferred = memory.forget(std::set<int>());
// Only ids[0] survives. ids[1] is gated by lastInSTM->hasLink, ids[2] by its
// kNeighbor link to ids[3] (STM).
ASSERT_EQ((int)transferred.size(), 1);
EXPECT_EQ(transferred.front(), ids[0]);
EXPECT_TRUE(memory.isInLTM(ids[0]));
EXPECT_TRUE(memory.isInWM(ids[1]));
EXPECT_TRUE(memory.isInWM(ids[2]));
}
// --- Variant B: swap the loop closure. Remove the previous A-style link
// ids[3]->ids[1], add a B-style link ids[2]->ids[4]. ids[2] now hits the
// inner foundInSTM gate (its loop-closure link points into STM).
memory.removeLink(ids[3], ids[1]);
ASSERT_TRUE(memory.addLink(Link(ids[2], ids[4], Link::kGlobalClosure, Transform::getIdentity(), infMatrix)));
{
// ids[0] is gone from the previous forget pass; only ids[1] and ids[2] remain
// in WM. ids[2] now fails the foundInSTM check (its loop closure to ids[4]
// is a link into STM). ids[1]'s links are: ids[0] (LTM, key>0 still counted in
// the candidate's own links) and ids[2] (WM) and (now) ids[3] is no longer in
// its links since we removed the previous loop closure. So ids[1] is the only
// transferable candidate.
std::list<int> transferred = memory.forget(std::set<int>());
ASSERT_EQ((int)transferred.size(), 1);
EXPECT_EQ(transferred.front(), ids[1]);
EXPECT_TRUE(memory.isInLTM(ids[1]));
EXPECT_TRUE(memory.isInWM(ids[2])); // still pinned by its loop-closure link to STM
}
memory.close(false);
}
TEST(MemoryTest, ForgetTransfersByWeightOrderAscending)
{
// WeightAgeIdKey orders candidates by (weight, age, id) ascending, so
// getRemovableSignatures returns the *lowest-weight* candidate first. With
// custom weights {ids[0]=5, ids[1]=1, ids[2]=10} (ids[2] is excluded by the
// STM-link gate anyway), forget pulls ids[1] first (weight=1) and ids[0] second
// (weight=5), in that order.
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
{
const_cast<Signature *>(memory.getSignature(ids[0]))->setWeight(5);
const_cast<Signature *>(memory.getSignature(ids[1]))->setWeight(1);
const_cast<Signature *>(memory.getSignature(ids[2]))->setWeight(10);
}
std::list<int> transferred = memory.forget(std::set<int>());
// Both ids[1] and ids[0] are removable; ids[2] is gated by its kNeighbor link to
// STM. forget keeps draining until signaturesRemoved >= _signaturesAdded+1 = 2.
ASSERT_EQ((int)transferred.size(), 2);
auto it = transferred.begin();
EXPECT_EQ(*it++, ids[1]); // weight 1 -- lowest -- comes first
EXPECT_EQ(*it, ids[0]); // weight 5 -- next
memory.close(false);
}
TEST(MemoryTest, ForgetIgnoresIntermediateNodes)
{
// Intermediate nodes (weight==-1) are excluded by the weight>=0 gate inside
// Memory::getRemovableSignatures. But Memory::forget also has a follow-up step
// that drags intermediate *neighbors* of a transferred signature along with it
// (the intermediate-node propagation loops in both branches of Memory::forget).
// With STM=2 + 5 updates and ids[0] turned intermediate, ids[1] is the only
// direct candidate; once forget transfers ids[1] it then traverses ids[1]'s
// kNeighbor links and drags ids[0] along because it has weight==-1.
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
memory.convertToIntermediate(ids[0]);
ASSERT_EQ(memory.getSignature(ids[0])->getWeight(), -1);
std::list<int> transferred = memory.forget(std::set<int>());
// getRemovableSignatures sees only ids[1] as a candidate (ids[0] gated by weight,
// ids[2] by the STM-link gate). After ids[1] is transferred, forget then transfers
// the intermediate neighbor ids[0] in the same call.
std::set<int> transferredSet(transferred.begin(), transferred.end());
EXPECT_EQ((int)transferredSet.size(), 2);
EXPECT_EQ(transferredSet.count(ids[1]), 1u);
EXPECT_EQ(transferredSet.count(ids[0]), 1u);
// ids[1] is the "real" candidate -- it should appear before ids[0] in the order
// reported by forget.
EXPECT_EQ(transferred.front(), ids[1]);
EXPECT_TRUE(memory.isInLTM(ids[0]));
EXPECT_TRUE(memory.isInLTM(ids[1]));
EXPECT_TRUE(memory.isInWM(ids[2]));
memory.close(false);
}
TEST(MemoryTest, ForgetHonorsIgnoredIds)
{
// Same setup as ForgetTransfersOldestWmSignaturesToLtm, but request that ids[0]
// stays in WM via the ignoredIds set. forget() should then transfer ids[1] only
// (ids[2] is still pinned by the STM link).
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
std::set<int> ignored;
ignored.insert(ids[0]); // pin the oldest in WM
std::list<int> transferred = memory.forget(ignored);
ASSERT_EQ((int)transferred.size(), 1);
EXPECT_EQ(transferred.front(), ids[1]);
EXPECT_TRUE(memory.isInWM(ids[0]));
EXPECT_FALSE(memory.isInWM(ids[1]));
EXPECT_TRUE(memory.isInWM(ids[2]));
memory.close(false);
}
// ---------------------------------------------------------------------------
// Save / load 2D map
// ---------------------------------------------------------------------------
TEST(MemoryTest, Save2DMapRoundtripsWhenDatabaseOpen)
{
const std::string dbPath = uniqueDbPath();
{
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(dbPath, true));
cv::Mat map(4, 4, CV_8SC1);
map.setTo(-1);
map.at<char>(1, 1) = 0;
map.at<char>(2, 2) = 100;
memory.save2DMap(map, -1.0f, -1.0f, 0.5f);
float xMin = 0.0f, yMin = 0.0f, cellSize = 0.0f;
cv::Mat loaded = memory.load2DMap(xMin, yMin, cellSize);
ASSERT_EQ(loaded.rows, 4);
ASSERT_EQ(loaded.cols, 4);
EXPECT_NEAR(xMin, -1.0f, 1e-5f);
EXPECT_NEAR(yMin, -1.0f, 1e-5f);
EXPECT_NEAR(cellSize, 0.5f, 1e-5f);
memory.close(true);
}
UFile::erase(dbPath.c_str());
}
// ---------------------------------------------------------------------------
// Get*Memory / weights
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetWeightsReturnsWmEntriesOnly)
{
// getWeights() iterates _workingMem and skips STM. With STM=5 (default fixture),
// the first 2 signatures are still in STM, so WM is empty and so is the result.
for(int i = 0; i < 2; ++i)
{
ASSERT_TRUE(update());
}
EXPECT_EQ(memory_->getWeights().size(), 0u);
// Add enough signatures so the oldest get promoted from STM to WM. After 7
// updates with STM=5, 2 signatures are in WM.
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(update());
}
std::map<int, int> weights = memory_->getWeights();
EXPECT_EQ((int)weights.size(), (int)memory_->getWorkingMem().size());
// Without rehearsal merges (kMemRehearsalSimilarity=1.0 in the fixture) every
// signature keeps its default weight of 0.
for(auto & p: weights)
{
EXPECT_EQ(p.second, 0);
}
}
TEST_F(MemoryFixture, GetMaxStMemSizeReflectsParameter)
{
EXPECT_EQ(memory_->getMaxStMemSize(), 5);
}
// ---------------------------------------------------------------------------
// Node info
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetOdomPoseMatchesPassedPose)
{
const Transform pose(2.5f, -1.0f, 0.5f, 0.1f, 0.2f, 0.3f);
ASSERT_TRUE(updateWith(pose));
const int id = memory_->getLastSignatureId();
const Transform got = memory_->getOdomPose(id);
EXPECT_NEAR(got.x(), pose.x(), 1e-4f);
EXPECT_NEAR(got.y(), pose.y(), 1e-4f);
EXPECT_NEAR(got.z(), pose.z(), 1e-4f);
}
TEST_F(MemoryFixture, GetNodeInfoReturnsTrueForExistingNode)
{
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
Transform odomPose, groundTruth;
int mapId = -1, weight = -1;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
EXPECT_TRUE(memory_->getNodeInfo(id, odomPose, mapId, weight, label, stamp, groundTruth, velocity, gps, sensors));
EXPECT_EQ(mapId, 0); // first map id is 0
}
TEST_F(MemoryFixture, GetNodeInfoReturnsFalseForUnknownNode)
{
Transform odomPose, groundTruth;
int mapId = -1, weight = -1;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
EXPECT_FALSE(memory_->getNodeInfo(999, odomPose, mapId, weight, label, stamp, groundTruth, velocity, gps, sensors));
}
// ---------------------------------------------------------------------------
// computeLikelihood
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, ComputeLikelihoodEmptyIdsReturnsEmpty)
{
ASSERT_TRUE(update());
const Signature * s = memory_->getSignature(memory_->getLastSignatureId());
ASSERT_NE(s, nullptr);
std::list<int> ids;
std::map<int, float> likelihood = memory_->computeLikelihood(s, ids);
EXPECT_EQ(likelihood.size(), 0u);
}
// ---------------------------------------------------------------------------
// createSignature (exercised through Memory::update)
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, CreateSignatureRejectsIntermediateAsFirstNode)
{
// data.id() < 0 marks the input as an intermediate node. The very first node in
// memory cannot be intermediate; createSignature returns null and update() reports
// false. Memory must remain empty.
SensorData data(image_);
data.setId(-1);
EXPECT_FALSE(memory_->update(data, Transform(0, 0, 0, 0, 0, 0), covariance_));
EXPECT_EQ(memory_->getLastSignatureId(), 0);
EXPECT_EQ(memory_->getStMem().size(), 0u);
}
TEST_F(MemoryFixture, CreateSignatureAcceptsIntermediateNodeAfterFirst)
{
// Once a normal node exists, a follow-up update with data.id()<0 produces an
// intermediate signature: it is created with weight==-1, even though
// kMemGenerateIds=true overwrites the negative id with the next auto-generated one.
ASSERT_TRUE(update());
const int firstId = memory_->getLastSignatureId();
SensorData intermediateData(image_);
intermediateData.setId(-1);
ASSERT_TRUE(memory_->update(intermediateData, Transform(1.0f, 0, 0, 0, 0, 0), covariance_));
const int secondId = memory_->getLastSignatureId();
EXPECT_EQ(secondId, firstId + 1); // auto-generated, not -1
const Signature * s = memory_->getSignature(secondId);
ASSERT_NE(s, nullptr);
EXPECT_EQ(s->getWeight(), -1); // tagged as intermediate
}
TEST(MemoryTest, CreateSignatureRejectsZeroIdWhenGenerateIdsOff)
{
// kMemGenerateIds=false: createSignature requires every input to have a positive
// id (since it never auto-generates). data.id()==0 fails immediately.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemGenerateIds()] = "false";
Memory memory(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(0);
EXPECT_FALSE(memory.update(data, Transform(0, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 0);
}
TEST(MemoryTest, CreateSignatureRejectsIdNotStrictlyGreaterWhenGenerateIdsOff)
{
// kMemGenerateIds=false: subsequent ids must be strictly greater than _idCount.
// A re-used id, or any id <= _idCount, fails.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemGenerateIds()] = "false";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
SensorData first(image);
first.setId(5);
ASSERT_TRUE(memory.update(first, Transform(0, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 5);
// Reject lower id.
SensorData second(image);
second.setId(3);
EXPECT_FALSE(memory.update(second, Transform(1, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 5);
// Reject equal id.
SensorData third(image);
third.setId(5);
EXPECT_FALSE(memory.update(third, Transform(2, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 5);
// Accept higher id.
SensorData fourth(image);
fourth.setId(7);
ASSERT_TRUE(memory.update(fourth, Transform(3, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 7);
}
TEST(MemoryTest, CreateSignatureRejectsDepthWithoutCameraModel)
{
// createSignature aborts when data has a depth/right image but no monocular or
// stereo calibration AND the odometry pose is non-null. This catches misconfigured
// pipelines early.
ParametersMap params = defaultMemoryParams();
Memory memory(params);
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar::all(0));
cv::Mat depth(8, 8, CV_16UC1, cv::Scalar(1000));
SensorData data;
data.setRGBDImage(rgb, depth, std::vector<CameraModel>()); // no models
data.setId(0);
EXPECT_FALSE(memory.update(data, Transform(0.1f, 0, 0, 0, 0, 0), covariance));
EXPECT_EQ(memory.getLastSignatureId(), 0);
}
TEST_F(MemoryFixture, CreateSignatureAutoIncrementsIdWhenGenerateIdsOn)
{
// Default kMemGenerateIds=true: every successful update grabs a new id from
// getNextId(), regardless of what data.id() contained (so long as it isn't
// negative -- that means intermediate, handled above).
SensorData first(image_);
first.setId(100); // input id is ignored when generating
ASSERT_TRUE(memory_->update(first, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int id1 = memory_->getLastSignatureId();
EXPECT_EQ(id1, Memory::kIdStart + 1);
SensorData second(image_);
second.setId(42); // again ignored
ASSERT_TRUE(memory_->update(second, Transform(1, 0, 0, 0, 0, 0), covariance_));
EXPECT_EQ(memory_->getLastSignatureId(), id1 + 1);
}
TEST(MemoryTest, CreateSignaturePostDecimatesImageWhenPostDecimationGreaterThanOne)
{
// kMemImagePostDecimation > 1 causes createSignature to downsample the RGB image
// by that integer factor and scale the CameraModel accordingly (post-decimation
// block in Memory::createSignature). Verify with a 16x16 image and decimation 2:
// the signature's stored imageRaw should be 8x8 and the CameraModel scaled to half.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true"; // keep raw image
params[Parameters::kMemImagePostDecimation()] = "2";
params[Parameters::kRtabmapImagesAlreadyRectified()] = "true"; // skip rectification
Memory memory(params);
const cv::Mat image(16, 16, CV_8UC1, cv::Scalar(64));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
const CameraModel model(10.0, 10.0, 8.0, 8.0,
CameraModel::opticalRotation(), 0.0, cv::Size(16, 16));
SensorData data;
data.setRGBDImage(image, cv::Mat(), std::vector<CameraModel>{model});
data.setId(0);
ASSERT_TRUE(memory.update(data, Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
const Signature * s = memory.getSignature(id);
ASSERT_NE(s, nullptr);
// Image is downsampled by the decimation factor.
EXPECT_EQ(s->sensorData().imageRaw().rows, 8);
EXPECT_EQ(s->sensorData().imageRaw().cols, 8);
// CameraModel is scaled by 1 / kMemImagePostDecimation.
ASSERT_EQ(s->sensorData().cameraModels().size(), 1u);
EXPECT_EQ(s->sensorData().cameraModels()[0].imageWidth(), 8);
EXPECT_EQ(s->sensorData().cameraModels()[0].imageHeight(), 8);
}
// Exhaustive checks of the rotation logic itself (all four roll cases, marker
// positions, dimension swaps) live in test_util2d (RotateImagesUpsideUpIfNecessary*).
// Here we just verify that Memory actually invokes util2d::rotateImagesUpsideUpIfNecessary
// when kMemRotateImagesUpsideUp=true, using a single 90 deg case.
TEST(MemoryTest, CreateSignatureRotatesImageUpsideUpWhenEnabled)
{
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true";
params[Parameters::kMemRotateImagesUpsideUp()] = "true";
params[Parameters::kRtabmapImagesAlreadyRectified()] = "true"; // skip rectification
Memory memory(params);
// 8 rows x 16 cols (16 wide, 8 tall). Base gray = 64; marker = 200 at (2, 3).
// +roll = pi/2 (camera tilted right) -> upright correction is a 90 CW rotation:
// transpose then flip(axis=1). The marker at (2, 3) lands at (3, 5) in 16x8.
const uint8_t kBase = 64;
const uint8_t kMarker = 200;
cv::Mat image(8, 16, CV_8UC1, cv::Scalar(kBase));
image.at<uint8_t>(2, 3) = kMarker;
// util2d::rotateImagesUpsideUpIfNecessary inspects
// model.localTransform() * opticalRotation().inverse()
// so we set: model.localTransform() = R(roll=+pi/2) * opticalRotation().
const Transform rolled(0.0f, 0.0f, 0.0f, (float)M_PI / 2.0f, 0.0f, 0.0f);
const CameraModel model(10.0, 10.0, 8.0, 4.0,
rolled * CameraModel::opticalRotation(), 0.0, cv::Size(16, 8));
SensorData data;
data.setRGBDImage(image, cv::Mat(), std::vector<CameraModel>{model});
data.setId(0);
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(data, Transform(0, 0, 0, 0, 0, 0), covariance));
const Signature * s = memory.getSignature(memory.getLastSignatureId());
ASSERT_NE(s, nullptr);
const cv::Mat & rotated = s->sensorData().imageRaw();
EXPECT_EQ(rotated.rows, 16);
EXPECT_EQ(rotated.cols, 8);
ASSERT_EQ(s->sensorData().cameraModels().size(), 1u);
EXPECT_EQ(s->sensorData().cameraModels()[0].imageWidth(), 8);
EXPECT_EQ(s->sensorData().cameraModels()[0].imageHeight(), 16);
EXPECT_EQ(rotated.at<uint8_t>(3, 5), kMarker);
}
TEST_F(MemoryFixture, CreateSignatureStoresPoseOnSignature)
{
// The pose passed to update() is stored on the signature.
SensorData data(image_);
data.setId(0);
const Transform pose(2.5f, -1.0f, 0.5f, 0.0f, 0.0f, 0.3f);
ASSERT_TRUE(memory_->update(data, pose, covariance_));
const int id = memory_->getLastSignatureId();
Transform odomPose, groundTruth;
int mapId = -1, weight = -1;
std::string label;
double stamp = 0.0;
std::vector<float> velocity;
GPS gps;
EnvSensors sensors;
ASSERT_TRUE(memory_->getNodeInfo(id, odomPose, mapId, weight, label, stamp, groundTruth, velocity, gps, sensors));
EXPECT_NEAR(odomPose.x(), pose.x(), 1e-4f);
EXPECT_NEAR(odomPose.y(), pose.y(), 1e-4f);
EXPECT_NEAR(odomPose.z(), pose.z(), 1e-4f);
float r, p, y;
odomPose.getEulerAngles(r, p, y);
EXPECT_NEAR(y, 0.3f, 1e-4f);
// First node gets the default map id 0 (no incrementMapId() yet).
EXPECT_EQ(mapId, 0);
}
// ---------------------------------------------------------------------------
// getNodeData
// ---------------------------------------------------------------------------
TEST(MemoryTest, GetNodeDataReturnsEmptyForUnknownIdWithoutDb)
{
// No init() called -> no DB driver. Unknown id falls through both branches
// (signature is null, _dbDriver is null) and returns a default-constructed
// SensorData with no image / scan / user data / occupancy grid.
Memory memory(defaultMemoryParams());
SensorData r = memory.getNodeData(99999, true, true, true, true);
EXPECT_EQ(r.imageCompressed().rows, 0);
EXPECT_EQ(r.imageRaw().rows, 0);
EXPECT_EQ(r.laserScanCompressed().size(), 0);
EXPECT_EQ(r.userDataCompressed().rows, 0);
EXPECT_FLOAT_EQ(r.gridCellSize(), 0.0f);
}
TEST(MemoryTest, GetNodeDataReturnsInMemoryPayloadsWhenSignatureNotSaved)
{
// kMemBinDataKept=true so update() leaves the compressed image in the signature.
// The signature is not saved (no _dbDriver call), so getNodeData takes the
// in-memory branch and returns the compressed image when images=true.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
const Signature * s = memory.getSignature(id);
ASSERT_NE(s, nullptr);
ASSERT_FALSE(s->isSaved());
ASSERT_EQ(s->sensorData().imageCompressed().rows, 1); // compressed image populated by update
SensorData r = memory.getNodeData(id, /*images=*/true, false, false, false);
EXPECT_EQ(r.imageCompressed().rows, 1);
EXPECT_EQ(r.imageCompressed().cols, s->sensorData().imageCompressed().cols);
}
TEST(MemoryTest, GetNodeDataMasksFieldsThatWereNotRequested)
{
// Even when a signature has all payloads populated, getNodeData must clear the
// fields the caller didn't request before returning.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true";
Memory memory(params);
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
ASSERT_EQ(memory.getSignature(id)->sensorData().imageCompressed().rows, 1);
// Ask for nothing: every field is masked to empty.
SensorData masked = memory.getNodeData(id, /*images=*/false, /*scan=*/false, /*userData=*/false, /*occupancyGrid=*/false);
EXPECT_EQ(masked.imageRaw().rows, 0);
EXPECT_EQ(masked.imageCompressed().rows, 0);
EXPECT_EQ(masked.laserScanCompressed().size(), 0);
EXPECT_EQ(masked.userDataCompressed().rows, 0);
EXPECT_FLOAT_EQ(masked.gridCellSize(), 0.0f);
// Ask only for images: image fields populated, others remain empty.
SensorData imagesOnly = memory.getNodeData(id, /*images=*/true, /*scan=*/false, /*userData=*/false, /*occupancyGrid=*/false);
EXPECT_EQ(imagesOnly.imageCompressed().rows, 1);
EXPECT_EQ(imagesOnly.laserScanCompressed().size(), 0);
EXPECT_EQ(imagesOnly.userDataCompressed().rows, 0);
EXPECT_FLOAT_EQ(imagesOnly.gridCellSize(), 0.0f);
}
TEST(MemoryTest, GetNodeDataLoadsEachPayloadTypeFromDatabase)
{
// When a signature is no longer in WM/STM (transferred to LTM by forget()),
// getSignature() returns null and getNodeData falls back to _dbDriver->getNodeData().
// We verify the DB branch returns each of the four selectable payload types
// (images, scan, userData, occupancyGrid) and that selective masking still works
// after a round-trip through the database.
ParametersMap params = defaultMemoryParams(/*stmSize=*/2);
params[Parameters::kMemBinDataKept()] = "true";
// Memory drops user-supplied occupancy grids unless RGBD/CreateOccupancyGrid is on
// (the occupancy-grid block in Memory::createSignature is gated on it). Enable it
// so our grid survives update().
params[Parameters::kRGBDCreateOccupancyGrid()] = "true";
Memory memory(params);
ASSERT_TRUE(memory.init(""));
const cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
const cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
// Build the SensorData for the *first* frame with all four payloads populated.
// LaserScan, user data and occupancy grid pass through Memory unchanged when
// laser-related downsampling/voxelization parameters are at their defaults (the
// fixture doesn't set kMemLaserScanDownsampleStepSize / kMemLaserScanVoxelSize).
SensorData seeded(image);
// Scan: a minimal 3D point cloud, 1 row of 4 XYZ points.
cv::Mat scanData(1, 4, CV_32FC3);
for(int i = 0; i < 4; ++i)
{
scanData.at<cv::Vec3f>(0, i) = cv::Vec3f(float(i), 0.0f, 0.0f);
}
const LaserScan scan(scanData, /*maxPoints=*/4, /*maxRange=*/10.0f, LaserScan::kXYZ);
seeded.setLaserScan(scan);
// User data: 4 floats.
cv::Mat userData = (cv::Mat_<float>(1, 4) << 1.0f, 2.0f, 3.0f, 4.0f);
seeded.setUserData(userData);
// Occupancy grid: a few obstacle cells.
cv::Mat obstacles(1, 3, CV_32FC3);
for(int i = 0; i < 3; ++i)
{
obstacles.at<cv::Vec3f>(0, i) = cv::Vec3f(float(i) * 0.1f, 0.0f, 0.0f);
}
const float kCellSize = 0.05f;
seeded.setOccupancyGrid(cv::Mat(), obstacles, cv::Mat(), kCellSize, cv::Point3f(0, 0, 0));
ASSERT_TRUE(memory.update(seeded, Transform(0.0f, 0.0f, 0.0f, 0, 0, 0), covariance));
const int targetId = memory.getLastSignatureId();
// Add more frames so the target signature ends up in WM and becomes a candidate
// for forget(). STM=2 so the first frame leaves STM after the third update.
for(int i = 1; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0.0f, 0.0f, 0, 0, 0), covariance));
}
std::list<int> transferred = memory.forget(std::set<int>());
const auto it = std::find(transferred.begin(), transferred.end(), targetId);
ASSERT_NE(it, transferred.end()); // target was transferred to LTM
ASSERT_EQ(memory.getSignature(targetId), nullptr);
// Wait for the async db writer to flush so subsequent reads see the persisted row.
memory.emptyTrash();
// LaserScan::size() on a compressed scan is the byte count of the zlib payload,
// which depends on the source data and isn't a useful exact-equality assertion.
// "Loaded" -> non-empty (data was returned) AND metadata (maxPoints, format)
// matches what we seeded. "Empty / masked" -> isEmpty() is true.
auto expectScanLoaded = [](const LaserScan & s, int expectedMaxPoints) {
EXPECT_FALSE(s.isEmpty());
EXPECT_EQ(s.maxPoints(), expectedMaxPoints);
EXPECT_EQ(s.format(), LaserScan::kXYZ);
};
auto expectScanEmpty = [](const LaserScan & s) {
EXPECT_TRUE(s.isEmpty());
};
// (1) images only.
{
SensorData r = memory.getNodeData(targetId, /*images=*/true, /*scan=*/false, /*userData=*/false, /*occupancyGrid=*/false);
EXPECT_EQ(r.imageCompressed().rows, 1); // image returned
expectScanEmpty(r.laserScanCompressed()); // scan masked
EXPECT_EQ(r.userDataCompressed().rows, 0); // user data masked
EXPECT_FLOAT_EQ(r.gridCellSize(), 0.0f); // grid masked
}
// (2) scan only.
{
SensorData r = memory.getNodeData(targetId, /*images=*/false, /*scan=*/true, /*userData=*/false, /*occupancyGrid=*/false);
EXPECT_EQ(r.imageCompressed().rows, 0);
expectScanLoaded(r.laserScanCompressed(), 4);
EXPECT_EQ(r.userDataCompressed().rows, 0);
EXPECT_FLOAT_EQ(r.gridCellSize(), 0.0f);
}
// (3) userData only.
{
SensorData r = memory.getNodeData(targetId, /*images=*/false, /*scan=*/false, /*userData=*/true, /*occupancyGrid=*/false);
EXPECT_EQ(r.imageCompressed().rows, 0);
expectScanEmpty(r.laserScanCompressed());
EXPECT_EQ(r.userDataCompressed().rows, 1);
EXPECT_FLOAT_EQ(r.gridCellSize(), 0.0f);
}
// (4) occupancy grid only.
{
SensorData r = memory.getNodeData(targetId, /*images=*/false, /*scan=*/false, /*userData=*/false, /*occupancyGrid=*/true);
EXPECT_EQ(r.imageCompressed().rows, 0);
expectScanEmpty(r.laserScanCompressed());
EXPECT_EQ(r.userDataCompressed().rows, 0);
EXPECT_FLOAT_EQ(r.gridCellSize(), kCellSize);
}
// All four together.
{
SensorData r = memory.getNodeData(targetId, /*images=*/true, /*scan=*/true, /*userData=*/true, /*occupancyGrid=*/true);
EXPECT_EQ(r.imageCompressed().rows, 1);
expectScanLoaded(r.laserScanCompressed(), 4);
EXPECT_EQ(r.userDataCompressed().rows, 1);
EXPECT_FLOAT_EQ(r.gridCellSize(), kCellSize);
}
memory.close(false);
}
// ---------------------------------------------------------------------------
// reactivateSignatures
// ---------------------------------------------------------------------------
TEST(MemoryTest, ReactivateSignaturesIsNoOpWhenAllInputsAreEmptyOrInMemory)
{
// Empty input -> empty result. IDs already in WM/STM are skipped (they don't
// hit the database, and they don't appear in the returned set).
Memory memory(defaultMemoryParams(2));
ASSERT_TRUE(memory.init(""));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int inMemId = memory.getLastSignatureId();
ASSERT_TRUE(memory.isInSTM(inMemId) || memory.isInWM(inMemId));
{
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(std::list<int>(), 0, timeDb);
EXPECT_EQ(result.size(), 0u);
}
{
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(std::list<int>{inMemId}, 0, timeDb);
EXPECT_EQ(result.size(), 0u);
EXPECT_TRUE(memory.isInSTM(inMemId) || memory.isInWM(inMemId));
}
memory.close(false);
}
TEST(MemoryTest, ReactivateSignaturesLoadsForgottenSignatureIntoWm)
{
// Setup mirrors ForgetTransfersOldestWmSignaturesToLtm: STM=2, 5 updates,
// forget() transfers ids[0] and ids[1] to LTM. reactivateSignatures must
// bring ids[0] back into WM while ids[1] remains in LTM.
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0, 0, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
ASSERT_EQ(memory.forget(std::set<int>()).size(), 2u);
ASSERT_TRUE(memory.isInLTM(ids[0]));
ASSERT_TRUE(memory.isInLTM(ids[1]));
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(std::list<int>{ids[0]}, 0, timeDb);
EXPECT_EQ(result.size(), 1u);
EXPECT_EQ(result.count(ids[0]), 1u);
EXPECT_TRUE(memory.isInWM(ids[0]));
EXPECT_TRUE(memory.isInLTM(ids[1]));
// The reloaded signature is fully queryable through getSignature().
EXPECT_NE(memory.getSignature(ids[0]), nullptr);
memory.close(false);
}
TEST(MemoryTest, ReactivateSignaturesRespectsMaxLoadedCapWithNormalNodes)
{
// Three normal (non-intermediate) signatures in LTM. Asking to reactivate all
// three with maxLoaded=2 must load exactly 2 and leave the third in LTM (the
// recursive queue load only fires when intermediate nodes are loaded).
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 6; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0, 0, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
// First forget() removes 2 oldest (ids[0], ids[1]); second forget() removes ids[2].
// ids[3] stays in WM (linked to ids[4] in STM and excluded).
memory.forget(std::set<int>());
memory.forget(std::set<int>());
ASSERT_TRUE(memory.isInLTM(ids[0]));
ASSERT_TRUE(memory.isInLTM(ids[1]));
ASSERT_TRUE(memory.isInLTM(ids[2]));
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(
std::list<int>{ids[0], ids[1], ids[2]}, 2, timeDb);
EXPECT_EQ(result.size(), 2u);
EXPECT_EQ(result.count(ids[0]), 1u);
EXPECT_EQ(result.count(ids[1]), 1u);
EXPECT_EQ(result.count(ids[2]), 0u);
EXPECT_TRUE(memory.isInWM(ids[0]));
EXPECT_TRUE(memory.isInWM(ids[1]));
EXPECT_TRUE(memory.isInLTM(ids[2]));
memory.close(false);
}
TEST(MemoryTest, ReactivateSignaturesRecursesWhenIntermediateNodesLoaded)
{
// When an intermediate node is loaded as part of the first batch, it does not
// count against maxLoaded -- the function recursively pulls intermediateNodesLoaded
// extra signatures from the queue. Here ids[1] is converted to intermediate before
// forget(), so with maxLoaded=2 and intermediate first in the list, the recursive
// call loads ids[0] from the queue and all 3 end up in WM.
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 6; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0, 0, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
memory.convertToIntermediate(ids[1]);
memory.forget(std::set<int>());
memory.forget(std::set<int>());
ASSERT_TRUE(memory.isInLTM(ids[0]));
ASSERT_TRUE(memory.isInLTM(ids[1]));
ASSERT_TRUE(memory.isInLTM(ids[2]));
// Intermediate (ids[1]) and ids[2] go into the first load batch (cap=2);
// ids[0] goes to the queue. ids[1] being intermediate triggers a recursive call
// that loads ids[0].
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(
std::list<int>{ids[1], ids[2], ids[0]}, 2, timeDb);
EXPECT_EQ(result.size(), 3u);
EXPECT_EQ(result.count(ids[0]), 1u);
EXPECT_EQ(result.count(ids[1]), 1u);
EXPECT_EQ(result.count(ids[2]), 1u);
EXPECT_TRUE(memory.isInWM(ids[0]));
EXPECT_TRUE(memory.isInWM(ids[1]));
EXPECT_TRUE(memory.isInWM(ids[2]));
// The reloaded intermediate keeps weight=-1.
ASSERT_NE(memory.getSignature(ids[1]), nullptr);
EXPECT_EQ(memory.getSignature(ids[1])->getWeight(), -1);
memory.close(false);
}
TEST(MemoryTest, ReactivateSignaturesRepopulatesLandmarkIndex)
{
// moveToTrash() (called via forget()) removes the landmark observer from
// _landmarksIndex; when the sole observer is removed, the landmark entry
// disappears entirely. reactivateSignatures() must rebuild that index entry.
ParametersMap params = defaultMemoryParams(2);
Memory memory(params);
ASSERT_TRUE(memory.init(""));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
const int landmarkPositiveId = 9;
const int landmarkInternalId = -landmarkPositiveId;
const cv::Mat lmCov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
SensorData first(image);
Landmarks landmarks;
landmarks.insert(std::make_pair(
landmarkPositiveId,
Landmark(landmarkPositiveId, 0.1f, Transform(1.0f, 0, 0, 0, 0, 0), lmCov)));
first.setLandmarks(landmarks);
ASSERT_TRUE(memory.update(first, Transform(0, 0, 0, 0, 0, 0), covariance));
const int landmarkObserver = memory.getLastSignatureId();
// Four more updates with no landmark, just to fill STM/WM so forget() can
// transfer the landmark observer to LTM.
for(int i = 1; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0, 0, 0, 0, 0), covariance));
}
// Pre-forget: landmark index has landmarkObserver.
{
const std::map<int, std::set<int> > & idx = memory.getLandmarksIndex();
ASSERT_EQ(idx.count(landmarkInternalId), 1u);
EXPECT_EQ(idx.at(landmarkInternalId).count(landmarkObserver), 1u);
}
ASSERT_EQ(memory.forget(std::set<int>()).size(), 2u);
ASSERT_TRUE(memory.isInLTM(landmarkObserver));
// After forget: sole observer is gone, so the landmark entry is erased.
{
const std::map<int, std::set<int> > & idx = memory.getLandmarksIndex();
EXPECT_EQ(idx.count(landmarkInternalId), 0u);
}
double timeDb = -1.0;
std::set<int> result = memory.reactivateSignatures(std::list<int>{landmarkObserver}, 0, timeDb);
EXPECT_EQ(result.size(), 1u);
EXPECT_TRUE(memory.isInWM(landmarkObserver));
// After reactivate: index entry is back with the observer.
{
const std::map<int, std::set<int> > & idx = memory.getLandmarksIndex();
ASSERT_EQ(idx.count(landmarkInternalId), 1u);
EXPECT_EQ(idx.at(landmarkInternalId).size(), 1u);
EXPECT_EQ(idx.at(landmarkInternalId).count(landmarkObserver), 1u);
}
memory.close(false);
}
// ---------------------------------------------------------------------------
// getMetricConstraints
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetMetricConstraintsLinearChainReturnsUniqueLinks)
{
// A-B-C-D chain. All 4 ids requested -> 4 poses, 3 unique neighbor links.
// Each underlying neighbor link is stored once (the dedup uses graph::findLink).
std::vector<int> ids;
for(int i = 0; i < 4; ++i)
{
ASSERT_TRUE(update());
ids.push_back(memory_->getLastSignatureId());
}
std::set<int> requested(ids.begin(), ids.end());
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints(requested, poses, links);
ASSERT_EQ(poses.size(), 4u);
for(int id : ids) EXPECT_EQ(poses.count(id), 1u);
// 3 unique edges in the chain.
ASSERT_EQ(links.size(), 3u);
for(const auto & kv : links)
{
EXPECT_EQ(kv.second.type(), Link::kNeighbor);
}
}
TEST_F(MemoryFixture, GetMetricConstraintsSubsetExcludesLinksToNonIncludedNodes)
{
// A-B-C-D, but request only {A, C}. A's only neighbor is B (not requested);
// C's neighbors are B and D (neither requested). So 2 poses, 0 links.
std::vector<int> ids;
for(int i = 0; i < 4; ++i)
{
ASSERT_TRUE(update());
ids.push_back(memory_->getLastSignatureId());
}
std::set<int> requested = {ids[0], ids[2]};
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints(requested, poses, links);
ASSERT_EQ(poses.size(), 2u);
EXPECT_EQ(poses.count(ids[0]), 1u);
EXPECT_EQ(poses.count(ids[2]), 1u);
EXPECT_EQ(links.size(), 0u);
}
TEST_F(MemoryFixture, GetMetricConstraintsSkipsUnknownIds)
{
// Two real signatures + a non-existent id. The unknown id is silently dropped
// because getOdomPose returns a null transform for it.
ASSERT_TRUE(update());
const int A = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int B = memory_->getLastSignatureId();
const int unknown = 9999;
std::set<int> requested = {A, unknown, B};
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints(requested, poses, links);
EXPECT_EQ(poses.count(A), 1u);
EXPECT_EQ(poses.count(B), 1u);
EXPECT_EQ(poses.count(unknown), 0u);
ASSERT_EQ(poses.size(), 2u);
ASSERT_EQ(links.size(), 1u);
}
TEST_F(MemoryFixture, GetMetricConstraintsMergesThroughIntermediateWhenNotLookingInDb)
{
// A-B-C with B intermediate. lookInDatabase=false -> B is excluded from poses
// and A's neighbor link to B is merged into a single A->C link.
ASSERT_TRUE(update());
const int A = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int B = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int C = memory_->getLastSignatureId();
memory_->convertToIntermediate(B);
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints({A, B, C}, poses, links, /*lookInDatabase=*/false, /*landmarksAdded=*/false);
ASSERT_EQ(poses.size(), 2u);
EXPECT_EQ(poses.count(A), 1u);
EXPECT_EQ(poses.count(B), 0u);
EXPECT_EQ(poses.count(C), 1u);
// One merged link from A to C (the chain through B).
ASSERT_EQ(links.size(), 1u);
const Link & merged = links.begin()->second;
EXPECT_EQ(merged.from(), A);
EXPECT_EQ(merged.to(), C);
}
TEST_F(MemoryFixture, GetMetricConstraintsKeepsIntermediateWhenLookingInDb)
{
// Same setup as above but with lookInDatabase=true: the intermediate-merge
// branch is bypassed, so B is kept in poses and both A-B and B-C links are
// returned individually.
ASSERT_TRUE(update());
const int A = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int B = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int C = memory_->getLastSignatureId();
memory_->convertToIntermediate(B);
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints({A, B, C}, poses, links, /*lookInDatabase=*/true, /*landmarksAdded=*/false);
ASSERT_EQ(poses.size(), 3u);
EXPECT_EQ(poses.count(A), 1u);
EXPECT_EQ(poses.count(B), 1u);
EXPECT_EQ(poses.count(C), 1u);
ASSERT_EQ(links.size(), 2u);
for(const auto & kv : links)
{
EXPECT_EQ(kv.second.type(), Link::kNeighbor);
}
}
TEST_F(MemoryFixture, GetMetricConstraintsIncludesLoopClosureLinkExactlyOnce)
{
// A-B-C chain plus a loop closure A<->C. Result must contain the loop link
// exactly once (not twice) thanks to the graph::findLink dedup.
ASSERT_TRUE(update());
const int A = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int B = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int C = memory_->getLastSignatureId();
ASSERT_TRUE(memory_->addLink(Link(A, C, Link::kGlobalClosure,
Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1))));
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints({A, B, C}, poses, links);
EXPECT_EQ(poses.size(), 3u);
ASSERT_EQ(links.size(), 3u);
int neighbors = 0, loops = 0;
for(const auto & kv : links)
{
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(MemoryFixture, GetMetricConstraintsExcludesLandmarkUnlessRequested)
{
// A signature with a landmark observation. landmarksAdded=false -> no landmark
// in poses, no landmark link. landmarksAdded=true -> landmark pose included
// and the inverted link is added (key = landmark id).
const int landmarkPositiveId = 11;
const int landmarkInternalId = -landmarkPositiveId;
const cv::Mat lmCov = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
SensorData data(image_);
Landmarks landmarks;
landmarks.insert(std::make_pair(
landmarkPositiveId,
Landmark(landmarkPositiveId, 0.1f, Transform(1.0f, 0, 0, 0, 0, 0), lmCov)));
data.setLandmarks(landmarks);
ASSERT_TRUE(memory_->update(data, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int A = memory_->getLastSignatureId();
{
SCOPED_TRACE("landmarksAdded=false");
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints({A}, poses, links, /*lookInDatabase=*/false, /*landmarksAdded=*/false);
ASSERT_EQ(poses.size(), 1u);
EXPECT_EQ(poses.count(A), 1u);
EXPECT_EQ(poses.count(landmarkInternalId), 0u);
EXPECT_EQ(links.size(), 0u);
}
{
SCOPED_TRACE("landmarksAdded=true");
std::map<int, Transform> poses;
std::multimap<int, Link> links;
memory_->getMetricConstraints({A}, poses, links, /*lookInDatabase=*/false, /*landmarksAdded=*/true);
ASSERT_EQ(poses.size(), 2u);
EXPECT_EQ(poses.count(A), 1u);
EXPECT_EQ(poses.count(landmarkInternalId), 1u);
ASSERT_EQ(links.size(), 1u);
EXPECT_EQ(links.begin()->first, landmarkInternalId);
EXPECT_EQ(links.begin()->second.type(), Link::kLandmark);
}
}
// ---------------------------------------------------------------------------
// cleanup
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, CleanupReturnsZeroWhenNoLastSignature)
{
// Fresh memory, no update() yet -> _lastSignature is null.
EXPECT_EQ(memory_->cleanup(), 0);
}
TEST_F(MemoryFixture, CleanupReturnsZeroForLastSignatureInDefaultMode)
{
// Default params: kMemBadSignaturesIgnored=false -> the bad-signature branch
// is disabled even though the last signature has no words (kKpMaxFeatures=-1).
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
EXPECT_EQ(memory_->cleanup(), 0);
// Signature still in STM.
EXPECT_TRUE(memory_->isInSTM(id));
}
TEST(MemoryTest, CleanupRemovesBadSignatureWhenBadSignaturesIgnored)
{
// kKpMaxFeatures=-1 -> every signature has 0 words -> isBadSignature() == true.
// kMemBadSignaturesIgnored=true gates the cleanup branch on. The non-intermediate
// last signature is removed and its id is returned.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBadSignaturesIgnored()] = "true";
Memory memory(params);
ASSERT_TRUE(memory.init(""));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
EXPECT_EQ(memory.cleanup(), id);
EXPECT_EQ(memory.getSignature(id), nullptr);
memory.close(false);
}
TEST(MemoryTest, CleanupRemovesLastSignatureInLocalizationMode)
{
// !_incrementalMemory -> cleanup() always removes the last signature regardless
// of bad-signature status.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemIncrementalMemory()] = "false";
Memory memory(params);
ASSERT_TRUE(memory.init(""));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
ASSERT_NE(memory.getSignature(id), nullptr);
EXPECT_EQ(memory.cleanup(), id);
EXPECT_EQ(memory.getSignature(id), nullptr);
memory.close(false);
}
// ---------------------------------------------------------------------------
// removeRawData
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, RemoveRawDataClearsImage)
{
// Default registration pipeline (kReg=0, RegistrationVis) does not require scan or
// userData; reextractLoopClosureFeatures defaults to false, so image clearing is
// also unconditional. removeRawData(id, true, true, true, true) should clear the
// signature's imageRaw and depthRaw.
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
ASSERT_FALSE(memory_->getSignature(id)->sensorData().imageRaw().empty());
memory_->removeRawData(id, /*image=*/true, /*scan=*/true, /*userData=*/true, /*occupancyGrid=*/true);
EXPECT_TRUE(memory_->getSignature(id)->sensorData().imageRaw().empty());
}
// ---------------------------------------------------------------------------
// updateAge
// ---------------------------------------------------------------------------
TEST(MemoryTest, UpdateAgeReordersForgetCandidatesByTimestamp)
{
// Memory::forget() orders removal candidates by weight, then age (timestamp in
// _workingMem). Touching ids[0] with updateAge() makes it the freshest, so the
// next forget() picks ids[1] (next-oldest) instead of ids[0].
ParametersMap params = defaultMemoryParams(1); // STM=1
Memory memory(params);
ASSERT_TRUE(memory.init(""));
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;
for(int i = 0; i < 5; ++i)
{
ASSERT_TRUE(memory.update(SensorData(image), Transform(float(i), 0, 0, 0, 0, 0), covariance));
ids.push_back(memory.getLastSignatureId());
}
// WM = {virtual, ids[0..3]}, STM = {ids[4]}. ids[3] linked to STM -> excluded.
// Removable in age order: ids[0], ids[1], ids[2].
// Refresh ids[0]'s timestamp so it becomes the freshest in WM. forget() should
// now pick ids[1] and ids[2] (next two oldest), leaving ids[0] alone.
memory.updateAge(ids[0]);
std::list<int> transferred = memory.forget(std::set<int>());
std::set<int> transferredSet(transferred.begin(), transferred.end());
EXPECT_EQ(transferredSet.count(ids[0]), 0u); // refreshed, not picked
EXPECT_EQ(transferredSet.count(ids[1]), 1u);
EXPECT_TRUE(memory.isInWM(ids[0]));
EXPECT_TRUE(memory.isInLTM(ids[1]));
memory.close(false);
}
// ---------------------------------------------------------------------------
// saveLocationData
// ---------------------------------------------------------------------------
TEST(MemoryTest, SaveLocationDataFlushesSignatureToOnDiskDatabase)
{
// saveLocationData gates on _dbDriver, !isInMemory(), incrementalMemory, !isSaved
// and id>0. With an on-disk database, the call must: (1) flip isSaved() to true,
// (2) clear the in-memory compressed payload, and (3) leave the signature still
// reachable in WM/STM (getSignature stays non-null).
const std::string dbPath = uniqueDbPath();
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true"; // keep image compressed in memory
{
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath, true));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
const Signature * sBefore = memory.getSignature(id);
ASSERT_NE(sBefore, nullptr);
ASSERT_FALSE(sBefore->isSaved());
ASSERT_FALSE(sBefore->sensorData().imageCompressed().empty());
memory.saveLocationData(id);
const Signature * sAfter = memory.getSignature(id);
ASSERT_NE(sAfter, nullptr);
EXPECT_TRUE(sAfter->isSaved());
EXPECT_TRUE(sAfter->sensorData().imageCompressed().empty());
memory.close(false);
}
UFile::erase(dbPath.c_str());
}
// ---------------------------------------------------------------------------
// removeVirtualLinks (per-id)
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, RemoveVirtualLinksClearsBothSidesOfTheLink)
{
// Add a virtual link between A and C, then call removeVirtualLinks(A): both A
// and C must lose the link. The non-virtual neighbor link between A and B is
// untouched.
ASSERT_TRUE(update());
const int A = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int B = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int C = memory_->getLastSignatureId();
ASSERT_TRUE(memory_->addLink(Link(A, C, Link::kVirtualClosure,
Transform::getIdentity(), cv::Mat::eye(6, 6, CV_64FC1))));
ASSERT_NE(memory_->getSignature(A)->getLinks().find(C), memory_->getSignature(A)->getLinks().end());
ASSERT_NE(memory_->getSignature(C)->getLinks().find(A), memory_->getSignature(C)->getLinks().end());
memory_->removeVirtualLinks(A);
// Virtual link gone from both endpoints.
EXPECT_EQ(memory_->getSignature(A)->getLinks().find(C), memory_->getSignature(A)->getLinks().end());
EXPECT_EQ(memory_->getSignature(C)->getLinks().find(A), memory_->getSignature(C)->getLinks().end());
// Neighbor A-B link is preserved.
EXPECT_NE(memory_->getSignature(A)->getLinks().find(B), memory_->getSignature(A)->getLinks().end());
}
// ---------------------------------------------------------------------------
// getMapId
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetMapIdReturnsZeroForFirstMap)
{
// Fresh memory uses mapId=0 for the first update. incrementMapId bumps it.
ASSERT_TRUE(update());
const int id1 = memory_->getLastSignatureId();
EXPECT_EQ(memory_->getMapId(id1), 0);
memory_->incrementMapId();
ASSERT_TRUE(update());
const int id2 = memory_->getLastSignatureId();
EXPECT_EQ(memory_->getMapId(id2), 1);
}
TEST_F(MemoryFixture, GetMapIdReturnsMinusOneForUnknownId)
{
EXPECT_EQ(memory_->getMapId(9999), -1);
}
// ---------------------------------------------------------------------------
// getImageCompressed
// ---------------------------------------------------------------------------
TEST(MemoryTest, GetImageCompressedReturnsInMemoryCompressedImage)
{
// kMemBinDataKept=true keeps imageCompressed populated on the in-memory signature.
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true";
Memory memory(params);
ASSERT_TRUE(memory.init(""));
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(SensorData(image), Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
cv::Mat compressed = memory.getImageCompressed(id);
EXPECT_FALSE(compressed.empty());
memory.close(false);
}
TEST_F(MemoryFixture, GetImageCompressedReturnsEmptyForUnknownId)
{
EXPECT_TRUE(memory_->getImageCompressed(9999).empty());
}
// ---------------------------------------------------------------------------
// getNodeWordsAndGlobalDescriptors / getNodeCalibration
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetNodeCalibrationReturnsAttachedCameraModel)
{
// Attach a CameraModel to the SensorData and verify getNodeCalibration returns it.
const CameraModel camera(500.0, 500.0, 4.0, 4.0,
CameraModel::opticalRotation(), 0.0, cv::Size(8, 8));
SensorData data(image_, camera);
data.setId(0);
ASSERT_TRUE(memory_->update(data, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int id = memory_->getLastSignatureId();
std::vector<CameraModel> models;
std::vector<StereoCameraModel> stereoModels;
memory_->getNodeCalibration(id, models, stereoModels);
ASSERT_EQ(models.size(), 1u);
EXPECT_EQ(models[0].imageWidth(), 8);
EXPECT_EQ(models[0].imageHeight(), 8);
EXPECT_DOUBLE_EQ(models[0].fx(), 500.0);
EXPECT_DOUBLE_EQ(models[0].fy(), 500.0);
EXPECT_EQ(stereoModels.size(), 0u);
}
TEST_F(MemoryFixture, GetNodeCalibrationIsNoOpForUnknownId)
{
std::vector<CameraModel> models;
std::vector<StereoCameraModel> stereoModels;
memory_->getNodeCalibration(9999, models, stereoModels);
EXPECT_EQ(models.size(), 0u);
EXPECT_EQ(stereoModels.size(), 0u);
}
TEST_F(MemoryFixture, GetNodeWordsReturnsSignaturesWords)
{
// With kKpMaxFeatures=-1, no features are extracted by the pipeline, so the
// signature has no words. The accessor still returns successfully with empty
// outputs.
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
std::multimap<int, int> words;
std::vector<cv::KeyPoint> wordsKpts;
std::vector<cv::Point3f> words3;
cv::Mat wordsDescriptors;
std::vector<GlobalDescriptor> globalDescriptors;
memory_->getNodeWordsAndGlobalDescriptors(id, words, wordsKpts, words3, wordsDescriptors, globalDescriptors);
EXPECT_EQ(words.size(), 0u);
EXPECT_EQ(wordsKpts.size(), 0u);
EXPECT_EQ(words3.size(), 0u);
EXPECT_EQ(wordsDescriptors.cols, 0);
EXPECT_EQ(globalDescriptors.size(), 0u);
}
// ---------------------------------------------------------------------------
// getGroundTruthPose
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetGroundTruthPoseReturnsAttachedGroundTruth)
{
const Transform gt(1.5f, -2.0f, 0.25f, 0.0f, 0.0f, 0.3f);
SensorData data(image_);
data.setGroundTruth(gt);
data.setId(0);
ASSERT_TRUE(memory_->update(data, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int id = memory_->getLastSignatureId();
const Transform retrieved = memory_->getGroundTruthPose(id);
EXPECT_TRUE(retrieved.getDistance(gt) < 1e-5f);
}
TEST_F(MemoryFixture, GetGroundTruthPoseReturnsNullForUnknownId)
{
EXPECT_TRUE(memory_->getGroundTruthPose(9999).isNull());
}
// ---------------------------------------------------------------------------
// getGPS
// ---------------------------------------------------------------------------
TEST_F(MemoryFixture, GetGpsReturnsAttachedGps)
{
// GPS attached directly to the signature -> returned verbatim, offsetENU stays
// identity.
SensorData data(image_);
const GPS attached(/*stamp=*/1234.5, /*lon=*/-73.5, /*lat=*/45.5, /*alt=*/30.0, /*err=*/2.0, /*bearing=*/0.0);
data.setGPS(attached);
data.setId(0);
ASSERT_TRUE(memory_->update(data, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int id = memory_->getLastSignatureId();
GPS gps;
Transform offsetENU;
memory_->getGPS(id, gps, offsetENU, /*lookInDatabase=*/false, /*maxGraphDepth=*/0);
EXPECT_DOUBLE_EQ(gps.stamp(), attached.stamp());
EXPECT_DOUBLE_EQ(gps.longitude(), attached.longitude());
EXPECT_DOUBLE_EQ(gps.latitude(), attached.latitude());
EXPECT_TRUE(offsetENU.isIdentity());
}
TEST_F(MemoryFixture, GetGpsSearchesNeighborsWhenSignatureHasNoGps)
{
// Two-node chain A-B. GPS is only attached to B. Calling getGPS on A with
// maxGraphDepth=2 must walk the neighbor graph to B and return B's GPS.
const GPS bGps(/*stamp=*/4567.8, /*lon=*/-71.0, /*lat=*/46.8, /*alt=*/10.0, /*err=*/1.0, /*bearing=*/0.0);
SensorData dataA(image_);
ASSERT_TRUE(memory_->update(dataA, Transform(0, 0, 0, 0, 0, 0), covariance_));
const int A = memory_->getLastSignatureId();
SensorData dataB(image_);
dataB.setGPS(bGps);
ASSERT_TRUE(memory_->update(dataB, Transform(1, 0, 0, 0, 0, 0), covariance_));
GPS gps;
Transform offsetENU;
memory_->getGPS(A, gps, offsetENU, /*lookInDatabase=*/false, /*maxGraphDepth=*/2);
EXPECT_DOUBLE_EQ(gps.stamp(), bGps.stamp());
EXPECT_DOUBLE_EQ(gps.longitude(), bGps.longitude());
EXPECT_DOUBLE_EQ(gps.latitude(), bGps.latitude());
}
TEST_F(MemoryFixture, GetGpsReturnsDefaultWhenNoNeighborHasGps)
{
// Nobody has a GPS fix -> the function returns the default GPS (stamp=0) and
// identity offset.
ASSERT_TRUE(update());
const int id = memory_->getLastSignatureId();
GPS gps;
Transform offsetENU;
memory_->getGPS(id, gps, offsetENU, /*lookInDatabase=*/false, /*maxGraphDepth=*/0);
EXPECT_DOUBLE_EQ(gps.stamp(), 0.0);
EXPECT_TRUE(offsetENU.isIdentity());
}
// ---------------------------------------------------------------------------
// cleanupLocalGrids
// ---------------------------------------------------------------------------
TEST(MemoryTest, CleanupLocalGridsReturnsErrorWithoutDatabase)
{
// Without init() there is no _dbDriver -> the function logs an error and
// returns -1 regardless of inputs.
Memory memory(defaultMemoryParams());
std::map<int, Transform> poses{{1, Transform::getIdentity()}};
cv::Mat map(4, 4, CV_8UC1, cv::Scalar(0));
EXPECT_EQ(memory.cleanupLocalGrids(poses, map, 0.0f, 0.0f, 0.1f), -1);
}
TEST(MemoryTest, CleanupLocalGridsReturnsErrorWhenPosesEmpty)
{
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(""));
cv::Mat map(4, 4, CV_8UC1, cv::Scalar(0));
EXPECT_EQ(memory.cleanupLocalGrids({}, map, 0.0f, 0.0f, 0.1f), -1);
memory.close(false);
}
TEST(MemoryTest, CleanupLocalGridsReturnsErrorWhenMapEmpty)
{
Memory memory(defaultMemoryParams());
ASSERT_TRUE(memory.init(""));
std::map<int, Transform> poses{{1, Transform::getIdentity()}};
EXPECT_EQ(memory.cleanupLocalGrids(poses, cv::Mat(), 0.0f, 0.0f, 0.1f), -1);
memory.close(false);
}
TEST(MemoryTest, CleanupLocalGridsFiltersObstaclesAgainstMap)
{
// Setup: a signature with 3 obstacle cells at world positions
// (0.0, 0.0), (0.1, 0.0), (10.0, 10.0)
// and a 4x4 map with cellSize=1, xMin=-2, yMin=-2 where only the cell at
// world (0, 0) is marked as obstacle (=100). All other cells are free (=0).
// The first two obstacles fall on cells that map to the (0,0) area; the third
// falls outside the map and is dropped. Final obstacle count: 2 -> 2 (no
// change), or filtered depending on exact cell mapping; we assert the function
// runs and returns a non-negative count.
const std::string dbPath = uniqueDbPath();
ParametersMap params = defaultMemoryParams();
params[Parameters::kMemBinDataKept()] = "true";
params[Parameters::kRGBDCreateOccupancyGrid()] = "true";
{
Memory memory(params);
ASSERT_TRUE(memory.init(dbPath, true));
// Build an obstacles point cloud: 1 row, 3 cols, CV_32FC2 (2D points).
cv::Mat obstacles(1, 3, CV_32FC2);
obstacles.at<cv::Vec2f>(0, 0) = cv::Vec2f(0.0f, 0.0f);
obstacles.at<cv::Vec2f>(0, 1) = cv::Vec2f(0.1f, 0.0f);
obstacles.at<cv::Vec2f>(0, 2) = cv::Vec2f(10.0f, 10.0f);
const float kCellSize = 1.0f;
cv::Mat image(8, 8, CV_8UC1, cv::Scalar(128));
SensorData data(image);
data.setOccupancyGrid(/*ground=*/cv::Mat(), obstacles, /*empty=*/cv::Mat(), kCellSize, cv::Point3f(0, 0, 0));
data.setId(0);
cv::Mat covariance = cv::Mat::eye(6, 6, CV_64FC1) * 0.01;
ASSERT_TRUE(memory.update(data, Transform(0, 0, 0, 0, 0, 0), covariance));
const int id = memory.getLastSignatureId();
// Map: 4x4 cells, only (col=2, row=2) marked as obstacle (covers world (0,0)).
cv::Mat map(4, 4, CV_8UC1, cv::Scalar(0));
map.at<unsigned char>(2, 2) = 100;
// Pose: identity. xMin=-2, yMin=-2, cellSize=1 -> world (0,0) hits cell (2,2).
std::map<int, Transform> poses{{id, Transform::getIdentity()}};
const int modified = memory.cleanupLocalGrids(poses, map, /*xMin=*/-2.0f, /*yMin=*/-2.0f, kCellSize, /*cropRadius=*/0, /*filterScans=*/false);
// Two of the three obstacles land on the marked cell (kept); the (10,10)
// obstacle falls outside the map and is dropped. modified=1 grid touched.
EXPECT_EQ(modified, 1);
memory.close(false);
}
UFile::erase(dbPath.c_str());
}
// ---------------------------------------------------------------------------
// Memory::computeIcpTransformMulti(): proximity refinement between two nodes
// using their laser scans. Until now this only ran during the end-to-end
// replays, which need the fetched sample databases.
//
// A corner is used so ICP has a unique optimum (a single line or plane would
// leave a direction unobservable, making the result depend on the guess).
// ---------------------------------------------------------------------------
namespace {
// 2D corner: floor at y=-half, wall at x=-half, in the sensor frame.
LaserScan memoryCorner2D(float length = 4.0f, int pointsPerLine = 300, uint64_t seed = 0xBEEF)
{
cv::RNG rng(seed);
cv::Mat data(1, 2 * pointsPerLine, CV_32FC2);
const float half = 0.5f * length;
int idx = 0;
for(int i = 0; i < pointsPerLine; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
rng.uniform(-half, half), -half + rng.uniform(-0.005f, 0.005f));
}
for(int i = 0; i < pointsPerLine; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
-half + rng.uniform(-0.005f, 0.005f), rng.uniform(-half, half));
}
return LaserScan(data, /*maxPoints=*/0, /*maxRange=*/0.0f, LaserScan::kXY);
}
ParametersMap icpMemoryParams()
{
ParametersMap p = defaultMemoryParams();
p[Parameters::kRegStrategy()] = "1"; // ICP
p[Parameters::kRegForce3DoF()] = "true";
p[Parameters::kMemBinDataKept()] = "true"; // keep the scans
p[Parameters::kIcpPointToPlane()] = "false";
p[Parameters::kIcpVoxelSize()] = "0.0";
p[Parameters::kIcpCorrespondenceRatio()]= "0.1";
p[Parameters::kMemLaserScanVoxelSize()] = "0.0";
return p;
}
} // namespace
TEST_F(MemoryFixture, ComputeIcpTransformMultiAlignsCornerScans)
{
reinit(icpMemoryParams());
// Same corner seen from two viewpoints 12 cm / 2.9 deg apart.
const Transform motion(0.10f, 0.06f, 0.0f, 0.0f, 0.0f, 0.05f);
const LaserScan corner = memoryCorner2D();
SensorData first(image_);
first.setLaserScan(corner);
ASSERT_TRUE(memory_->update(first, Transform::getIdentity(), covariance_));
const int oldId = memory_->getLastSignatureId();
SensorData second(image_);
second.setLaserScan(util3d::transformLaserScan(corner, motion.inverse()));
ASSERT_TRUE(memory_->update(second, motion, covariance_));
const int newId = memory_->getLastSignatureId();
ASSERT_NE(oldId, newId);
std::map<int, Transform> poses;
poses.insert(std::make_pair(oldId, Transform::getIdentity()));
poses.insert(std::make_pair(newId, motion));
RegistrationInfo info;
const Transform t = memory_->computeIcpTransformMulti(newId, oldId, poses, &info);
ASSERT_FALSE(t.isNull()) << "ICP refinement failed: " << info.rejectedMsg;
// computeIcpTransformMulti(newId, oldId, ...) reports the transform from the
// new node to the old one, i.e. the inverse of the motion between them.
const Transform expected = motion.inverse();
EXPECT_LT(t.getDistance(expected), 0.002f)
<< "got " << t.prettyPrint() << " expected " << expected.prettyPrint();
const float angDeg = t.getAngle(expected) * 180.0f / static_cast<float>(CV_PI);
EXPECT_LT(angDeg, 0.2f) << "rotation off by " << angDeg << " deg";
EXPECT_GT(info.icpInliersRatio, 0.5f) << "poor overlap for two views of the same corner";
}
// Without scans there is nothing for ICP to align: the refinement must decline
// with a reason rather than returning a bogus transform.
TEST_F(MemoryFixture, ComputeIcpTransformMultiRejectsNodesWithoutScans)
{
reinit(icpMemoryParams());
ASSERT_TRUE(update());
const int oldId = memory_->getLastSignatureId();
ASSERT_TRUE(update());
const int newId = memory_->getLastSignatureId();
std::map<int, Transform> poses;
poses.insert(std::make_pair(oldId, Transform::getIdentity()));
poses.insert(std::make_pair(newId, Transform(1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f)));
RegistrationInfo info;
const Transform t = memory_->computeIcpTransformMulti(newId, oldId, poses, &info);
EXPECT_TRUE(t.isNull()) << "expected refusal, got " << t.prettyPrint();
EXPECT_FALSE(info.rejectedMsg.empty())
<< "declined without a reason, so a caller cannot tell 'no scans' from "
"a genuine registration failure";
EXPECT_LE(info.icpInliersRatio, 0.0f);
}
// The registration is also declined when the guess puts the two viewpoints
// beyond their combined sensor range, since the scans then cannot overlap. That
// early-out reports its own reason, so a caller can tell it apart from a
// failed alignment.
TEST_F(MemoryFixture, ComputeIcpTransformMultiRejectsScansTooFarApart)
{
reinit(icpMemoryParams());
// Same corner, but with a declared 2 m max range.
const LaserScan corner = memoryCorner2D();
const LaserScan ranged(corner.data(), corner.maxPoints(), /*rangeMax=*/2.0f, corner.format());
ASSERT_GT(ranged.rangeMax(), 0.0f);
SensorData first(image_);
first.setLaserScan(ranged);
ASSERT_TRUE(memory_->update(first, Transform::getIdentity(), covariance_));
const int oldId = memory_->getLastSignatureId();
SensorData second(image_);
second.setLaserScan(ranged);
const Transform farAway(20.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
ASSERT_TRUE(memory_->update(second, farAway, covariance_));
const int newId = memory_->getLastSignatureId();
// 20 m apart with 2 m + 2 m of range: no overlap is possible.
std::map<int, Transform> poses;
poses.insert(std::make_pair(oldId, Transform::getIdentity()));
poses.insert(std::make_pair(newId, farAway));
RegistrationInfo info;
const Transform t = memory_->computeIcpTransformMulti(newId, oldId, poses, &info);
EXPECT_TRUE(t.isNull()) << "aligned scans that cannot overlap: " << t.prettyPrint();
ASSERT_FALSE(info.rejectedMsg.empty()) << "declined without a reason";
EXPECT_NE(info.rejectedMsg.find("Too far"), std::string::npos)
<< "unexpected reason: " << info.rejectedMsg;
}