Fix possible staled flann index (#1742)

* Fix possible staled flann index

* fixed compatible crc flann index after repair and reload

* Added tests

* test coverage

* testing another possible branch

* removed doxygen tag version not opulated yet

* updated codedev to ignore test code

* Disabled all mac intel ci flaky builds
This commit is contained in:
matlabbe
2026-08-07 20:48:41 -07:00
committed by GitHub
parent c5ecfef101
commit 47ce2b0c74
11 changed files with 488 additions and 33 deletions

View File

@@ -2040,6 +2040,269 @@ TEST(MemoryTest, SetDummyDictionaryIgnoredAfterInit)
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());
}
TEST(MemoryTest, ForgetTransfersBasedOnWordCountInWordRegime)
{
// Branch (1) of Memory::forget() is gated on:

View File

@@ -756,6 +756,162 @@ TEST_F(VWDictionaryTest, SerializeDeserializeIndex)
}
}
namespace {
// One-hot descriptor, so every word is far from every other one and the
// checksum over the search data changes as soon as two words are swapped.
cv::Mat oneHotDescriptor(int hotIndex, int dim = 8)
{
cv::Mat descriptor = cv::Mat::zeros(1, dim, CV_32F);
descriptor.at<float>(0, hotIndex) = 1000.0f;
return descriptor;
}
} // namespace
TEST_F(VWDictionaryTest, RebuildIndexReordersIndexToWordIdOrder)
{
// A dictionary loaded from a database gets its words back in word-id order
// (std::map), and deserializeIndex() rebuilds the search data in that same
// order. So a serialized index is only reusable if it was built in word-id
// order too.
//
// update() with incremental FLANN appends the not-yet-indexed words at the
// end of the existing index, whatever their id. That is what Memory does
// when it repairs a dictionary that is missing words: the repaired words
// usually have ids *lower* than the ones already indexed, so the index ends
// up in an order the next load cannot reproduce. rebuildIndex() re-indexes
// everything from scratch, which restores the word-id order.
dict->setNNStrategy(VWDictionary::kNNFlannKdTree);
ASSERT_TRUE(dict->isIncrementalFlann()) << "The out-of-order index only happens with incremental FLANN";
dict->addWord(new VisualWord(1, oneHotDescriptor(0)));
dict->addWord(new VisualWord(3, oneHotDescriptor(2)));
dict->update();
ASSERT_EQ(dict->getIndexedWordsCount(), 2u);
// Word 2 is indexed after word 3: index order (1, 3, 2) != id order (1, 2, 3).
dict->addWord(new VisualWord(2, oneHotDescriptor(1)));
dict->update();
ASSERT_EQ(dict->getIndexedWordsCount(), 3u);
std::vector<unsigned char> staleData = dict->serializeIndex();
#ifdef _WIN32
// FlannIndex::serializeIndex() is not implemented on Windows
// (see corelib/src/FlannIndex.cpp), so there is nothing to round-trip.
EXPECT_EQ(staleData.size(), 0u);
#else
ASSERT_GT(staleData.size(), 0u);
// Simulates the next load: same words, added in id order like DBDriver does.
{
VWDictionary reloaded;
reloaded.setNNStrategy(VWDictionary::kNNFlannKdTree);
reloaded.addWord(new VisualWord(1, oneHotDescriptor(0)));
reloaded.addWord(new VisualWord(2, oneHotDescriptor(1)));
reloaded.addWord(new VisualWord(3, oneHotDescriptor(2)));
EXPECT_FALSE(reloaded.deserializeIndex(staleData))
<< "An index built out of word-id order should be rejected on load";
}
// Same words, same content, but re-indexed from scratch.
dict->rebuildIndex();
EXPECT_EQ(dict->getIndexedWordsCount(), 3u);
EXPECT_EQ(dict->getNotIndexedWordsCount(), 0u);
std::vector<unsigned char> rebuiltData = dict->serializeIndex();
ASSERT_GT(rebuiltData.size(), 0u);
{
VWDictionary reloaded;
reloaded.setNNStrategy(VWDictionary::kNNFlannKdTree);
reloaded.addWord(new VisualWord(1, oneHotDescriptor(0)));
reloaded.addWord(new VisualWord(2, oneHotDescriptor(1)));
reloaded.addWord(new VisualWord(3, oneHotDescriptor(2)));
EXPECT_TRUE(reloaded.deserializeIndex(rebuiltData));
EXPECT_EQ(reloaded.getIndexedWordsCount(), 3u);
// A deserialized index doesn't need to be saved back.
EXPECT_FALSE(reloaded.isModified());
}
#endif
}
TEST_F(VWDictionaryTest, RebuildIndexKeepsWordsAndSearchResults)
{
dict->setNNStrategy(VWDictionary::kNNFlannKdTree);
cv::Mat descriptors = (cv::Mat_<float>(3, 2) <<
0.0f, 0.0f,
10.0f, 0.0f,
0.0f, 100.0f);
std::list<int> wordIds = dict->addNewWords(descriptors, 1);
dict->update();
ASSERT_EQ(wordIds.size(), 3u);
cv::Mat query = (cv::Mat_<float>(2, 2) <<
0.5f, 0.5f, // matches the first word
0.0f, 99.0f); // matches the third word
const std::vector<int> before = dict->findNN(query);
ASSERT_EQ(before.size(), 2u);
ASSERT_EQ(before[0], wordIds.front());
ASSERT_EQ(before[1], wordIds.back());
dict->rebuildIndex();
// Re-indexing doesn't touch the words themselves, only the search index.
EXPECT_EQ(dict->getVisualWords().size(), 3u);
EXPECT_EQ(dict->getIndexedWordsCount(), 3u);
EXPECT_EQ(dict->getNotIndexedWordsCount(), 0u);
EXPECT_EQ(dict->findNN(query), before);
// The index changed, so it has to be saved back (Memory::saveFlannIndex()
// only serializes a modified dictionary).
EXPECT_TRUE(dict->isModified());
}
TEST_F(VWDictionaryTest, RebuildIndexOnEmptyDictionaryIsSafe)
{
dict->setNNStrategy(VWDictionary::kNNFlannKdTree);
dict->rebuildIndex();
EXPECT_EQ(dict->getVisualWords().size(), 0u);
EXPECT_EQ(dict->getIndexedWordsCount(), 0u);
EXPECT_EQ(dict->getNotIndexedWordsCount(), 0u);
EXPECT_TRUE(dict->serializeIndex().empty());
}
TEST_F(VWDictionaryTest, ByteToFloatChangeRebuildsIndex)
{
// parseParameters() re-indexes through rebuildIndex() when the binary to
// float conversion changes, because the descriptors fed to the kd-tree
// change dimension (1 float per byte vs 1 float per bit).
dict->setNNStrategy(VWDictionary::kNNFlannKdTree);
cv::Mat descriptors = cv::Mat::zeros(3, 4, CV_8U);
for(int row = 0; row < descriptors.rows; ++row)
{
descriptors.at<unsigned char>(row, row) = 255;
}
dict->addNewWords(descriptors, 1);
dict->update();
ASSERT_EQ(dict->getIndexedWordsCount(), 3u);
ParametersMap params;
params.insert(ParametersPair(Parameters::kKpByteToFloat(), "true"));
dict->parseParameters(params);
EXPECT_EQ(dict->getVisualWords().size(), 3u);
EXPECT_EQ(dict->getIndexedWordsCount(), 3u);
EXPECT_EQ(dict->getNotIndexedWordsCount(), 0u);
// The re-indexed dictionary is still searchable, with the smaller
// byte-to-float descriptors this time.
const std::vector<int> matches = dict->findNN(descriptors.row(0));
ASSERT_EQ(matches.size(), 1u);
EXPECT_NE(matches[0], VWDictionary::ID_INVALID);
}
TEST_F(VWDictionaryTest, IsModified)
{
EXPECT_TRUE(dict->isModified());