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

@@ -23,7 +23,7 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
build_name: [macos-sequoia-apple-silicon, macos-tahoe-intel, macos-tahoe-apple-silicon, macos-tahoe-intel-cv5, macos-tahoe-apple-silicon-cv5] build_name: [macos-sequoia-apple-silicon, macos-tahoe-apple-silicon, macos-tahoe-apple-silicon-cv5]
include: include:
# macos-sequoia-intel temporarily disabled. Had some g2o optimizer issues. # macos-sequoia-intel temporarily disabled. Had some g2o optimizer issues.
#- build_name: macos-sequoia-intel #- build_name: macos-sequoia-intel
@@ -32,15 +32,15 @@ jobs:
- build_name: macos-sequoia-apple-silicon - build_name: macos-sequoia-apple-silicon
os: macos-15 os: macos-15
cv: opencv@4 cv: opencv@4
- build_name: macos-tahoe-intel # - build_name: macos-tahoe-intel
os: macos-26-intel # os: macos-26-intel
cv: opencv@4 # cv: opencv@4
- build_name: macos-tahoe-apple-silicon - build_name: macos-tahoe-apple-silicon
os: macos-26 os: macos-26
cv: opencv@4 cv: opencv@4
- build_name: macos-tahoe-intel-cv5 # - build_name: macos-tahoe-intel-cv5
os: macos-26-intel # os: macos-26-intel
cv: opencv # cv: opencv
- build_name: macos-tahoe-apple-silicon-cv5 - build_name: macos-tahoe-apple-silicon-cv5
os: macos-26 os: macos-26
cv: opencv cv: opencv

View File

@@ -100,9 +100,18 @@ jobs:
lcov "${LCOV_IGNORE[@]}" --extract lcov.info \ lcov "${LCOV_IGNORE[@]}" --extract lcov.info \
'${{ github.workspace }}/*' \ '${{ github.workspace }}/*' \
--output-file lcov.info --output-file lcov.info
# The test sources are the instrument, not the subject: a line in a
# test counts as uncovered only when the test skipped it (a defensive
# cleanup branch, a platform guard), which says nothing about the
# library. They are also near-fully covered by construction, so
# leaving them in inflates the overall number. Excluded here, before
# the upload, so the HTML artifact, the summary below and Codecov all
# report the same figure. Kept in sync with coverage-report.sh.
lcov "${LCOV_IGNORE[@]}" --remove lcov.info \ lcov "${LCOV_IGNORE[@]}" --remove lcov.info \
'*/sqlite3/*' \ '*/sqlite3/*' \
'*/rtflann/*' \ '*/rtflann/*' \
'*/corelib/test/*' \
'*/utilite/test/*' \
--output-file lcov.info --output-file lcov.info
lcov --summary lcov.info lcov --summary lcov.info

View File

@@ -5,6 +5,7 @@ rtabmap
[![Release][release-image]][releases] [![Release][release-image]][releases]
[![Downloads][downloads-image]][downloads] [![Downloads][downloads-image]][downloads]
[![codecov](https://codecov.io/gh/introlab/rtabmap/graph/badge.svg?token=mPwvfZMOia)](https://codecov.io/gh/introlab/rtabmap)
[![License][license-image]][license] [![License][license-image]][license]
[release-image]: https://img.shields.io/badge/release-0.23.1-green.svg?style=flat [release-image]: https://img.shields.io/badge/release-0.23.1-green.svg?style=flat

View File

@@ -1,35 +1,28 @@
# Codecov configuration -- https://docs.codecov.com/docs/codecov-yaml # Codecov configuration -- https://docs.codecov.com/docs/codecov-yaml
# #
# The coverage data itself is produced by .github/workflows/coverage.yml (lcov # Coverage data is produced by .github/workflows/coverage.yml (lcov over a Debug
# over a Debug build with ENABLE_COVERAGE=ON) and uploaded by # build with ENABLE_COVERAGE=ON) and uploaded by codecov/codecov-action; this
# codecov/codecov-action. This file only controls what Codecov reports back on a # file only controls what Codecov reports back on a pull request. Nothing is
# pull request. # posted unless the Codecov GitHub App has access to the repository.
#
# Note: nothing is posted to the PR unless the Codecov GitHub App is installed
# on the repository, and the upload step is skipped entirely when the
# CODECOV_TOKEN secret is unset.
# Annotate the lines of the diff that no test executed, inline in the # Mark uncovered added lines inline in the "Files changed" tab.
# "Files changed" tab.
github_checks: github_checks:
annotations: true annotations: true
coverage: coverage:
precision: 2 precision: 2
round: down round: down
range: "60...90" range: "10...90" # red/green scale: 10% is fully red, 90% fully green
status: status:
# Whole-project coverage, compared against the base commit: catches a slow # Catch a slow slide down without pinning an absolute number.
# slide down without pinning an absolute number.
project: project:
default: default:
target: auto target: auto
threshold: 1% threshold: 1%
# Coverage of the lines this pull request touches -- the number that answers # Coverage of the lines this pull request touches. Advisory: reported, but
# "is the new code tested?". Advisory for now: it is reported but does not # does not block the merge -- drop "informational" to make it gate.
# block the merge. Remove "informational" to make it a required check.
patch: patch:
default: default:
informational: true informational: true
@@ -37,4 +30,4 @@ coverage:
comment: comment:
layout: "condensed_header, diff, files" layout: "condensed_header, diff, files"
behavior: default behavior: default
require_changes: true # stay quiet on pull requests that don't move coverage require_changes: true # stay quiet when coverage doesn't move

View File

@@ -336,6 +336,11 @@ public:
* @return true if modified, false otherwise * @return true if modified, false otherwise
*/ */
bool isModified() const; bool isModified() const;
// Re-index all the words from scratch. The index then contains the words in
// the same order than the one built by update() on a dictionary freshly
// loaded from a database, which is required to serialize it (the serialized
// index is matched against the words in that order when deserialized).
void rebuildIndex();
/** /**
* @brief Serialize the search index to a byte vector * @brief Serialize the search index to a byte vector

View File

@@ -614,7 +614,12 @@ void Memory::loadDataFromDb(bool postInitClosingEvents)
UWARN("%s", msg.c_str()); UWARN("%s", msg.c_str());
if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit(msg)); if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit(msg));
_memoryChanged = true; // This will force rtabmap to save back the dictionary even if we don't process any new data _memoryChanged = true; // This will force rtabmap to save back the dictionary even if we don't process any new data
_vwd->update(); // Re-index from scratch instead of adding the words above to the
// index already built: the index would then contain them in a
// different order than the words loaded from the database, which
// makes the index saved on close (see saveFlannIndex()) rejected
// when it is deserialized on next load.
_vwd->rebuildIndex();
} }
} }
@@ -714,12 +719,21 @@ void Memory::close(bool databaseSaved, bool postInitClosingEvents, const std::st
UINFO("No changes added to database."); UINFO("No changes added to database.");
if(_dbDriver) if(_dbDriver)
{ {
if(!this->isReadOnly()) { if(this->isReadOnly())
{
if(_memoryChanged || _linksChanged || databaseNameChanged)
{
UWARN("Memory has been modified (nodes=%s links=%s name=%s) but the database is read-only, changes are not saved to database.",
_memoryChanged?"true":"false", _linksChanged?"true":"false", databaseNameChanged?"true":"false");
}
}
else if(databaseSaved)
{
saveFlannIndex(postInitClosingEvents); saveFlannIndex(postInitClosingEvents);
} }
else if(_memoryChanged || _linksChanged || databaseNameChanged) else if(_memoryChanged || _linksChanged || databaseNameChanged)
{ {
UWARN("Memory has been modified (nodes=%s links=%s name=%s) but the database is read-only, changes are not saved to database.", UWARN("Memory has been modified (nodes=%s links=%s name=%s) but databaseSaved=false, changes are not saved to database.",
_memoryChanged?"true":"false", _linksChanged?"true":"false", databaseNameChanged?"true":"false"); _memoryChanged?"true":"false", _linksChanged?"true":"false", databaseNameChanged?"true":"false");
} }
if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit(uFormat("Closing database \"%s\"...", _dbDriver->getUrl().c_str()))); if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit(uFormat("Closing database \"%s\"...", _dbDriver->getUrl().c_str())));
@@ -736,8 +750,10 @@ void Memory::close(bool databaseSaved, bool postInitClosingEvents, const std::st
{ {
UINFO("Saving memory..."); UINFO("Saving memory...");
if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit("Saving memory...")); if(postInitClosingEvents) UEventsManager::post(new RtabmapEventInit("Saving memory..."));
if(!_memoryChanged && _dbDriver) if(_dbDriver)
{ {
// Must be done before clear(), which clears the dictionary.
// saveFlannIndex() saves only if the dictionary has been modified.
saveFlannIndex(postInitClosingEvents); saveFlannIndex(postInitClosingEvents);
} }
this->clear(); this->clear();

View File

@@ -118,10 +118,7 @@ void VWDictionary::parseParameters(const ParametersMap & parameters)
if(!treeUpdated && byteToFloat!=_byteToFloat && _strategy == kNNFlannKdTree) if(!treeUpdated && byteToFloat!=_byteToFloat && _strategy == kNNFlannKdTree)
{ {
UINFO("KDTree: Binary to Float conversion approach has changed, re-initialize kd-tree."); UINFO("KDTree: Binary to Float conversion approach has changed, re-initialize kd-tree.");
_dataTree = cv::Mat(); this->rebuildIndex();
_notIndexedWords = uKeysSet(_visualWords);
_removedIndexedWords.clear();
this->update();
} }
if(incrementalDictionary) if(incrementalDictionary)
@@ -296,6 +293,15 @@ bool VWDictionary::isModified() const
return _modified; return _modified;
} }
void VWDictionary::rebuildIndex()
{
UDEBUG("Re-indexing all %ld words...", _visualWords.size());
_dataTree = cv::Mat();
_notIndexedWords = uKeysSet(_visualWords);
_removedIndexedWords.clear();
this->update();
}
bool VWDictionary::setNNStrategy(NNStrategy strategy) bool VWDictionary::setNNStrategy(NNStrategy strategy)
{ {
#if CV_MAJOR_VERSION < 3 #if CV_MAJOR_VERSION < 3

View File

@@ -2040,6 +2040,269 @@ TEST(MemoryTest, SetDummyDictionaryIgnoredAfterInit)
UFile::erase(dbPath.c_str()); 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) TEST(MemoryTest, ForgetTransfersBasedOnWordCountInWordRegime)
{ {
// Branch (1) of Memory::forget() is gated on: // 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) TEST_F(VWDictionaryTest, IsModified)
{ {
EXPECT_TRUE(dict->isModified()); EXPECT_TRUE(dict->isModified());

View File

@@ -134,9 +134,16 @@ echo "Filtering coverage data (repo sources only)..."
lcov "${LCOV_IGNORE[@]}" --extract "$INFO_FILE" \ lcov "${LCOV_IGNORE[@]}" --extract "$INFO_FILE" \
"${ROOT}/*" \ "${ROOT}/*" \
--output-file "$INFO_FILE" --output-file "$INFO_FILE"
# The test sources are the instrument, not the subject: a line in a test counts
# as uncovered only when the test skipped it (a defensive cleanup branch, a
# platform guard), which says nothing about the library. They are also
# near-fully covered by construction, so leaving them in inflates the overall
# number. Kept in sync with the same list in .github/workflows/coverage.yml.
lcov "${LCOV_IGNORE[@]}" --remove "$INFO_FILE" \ lcov "${LCOV_IGNORE[@]}" --remove "$INFO_FILE" \
'*/sqlite3/*' \ '*/sqlite3/*' \
'*/rtflann/*' \ '*/rtflann/*' \
'*/corelib/test/*' \
'*/utilite/test/*' \
--output-file "$INFO_FILE" --output-file "$INFO_FILE"
echo "Generating HTML..." echo "Generating HTML..."

View File

@@ -13,5 +13,4 @@
*/ */
window.RTABMAP_DOC_VERSIONS = [ window.RTABMAP_DOC_VERSIONS = [
['latest', 'latest'], ['latest', 'latest'],
['0.23.10', '0.23.10'],
]; ];