added appearance-based tests, set min gftt quality to quality level

This commit is contained in:
matlabbe
2026-06-02 21:53:36 -07:00
parent cf9d10c34f
commit 3b3a8e92ae
11 changed files with 605 additions and 49 deletions
+7 -1
View File
@@ -148,7 +148,8 @@ public:
kFeatureGfttDaisy=13, //new 0.20.6
kFeatureSurfDaisy=14, //new 0.20.6
kFeaturePyDetector=15, //new 0.20.8
kFeatureSuperPointRpautrat=16}; // new 0.23.3
kFeatureSuperPointRpautrat=16, // new 0.23.3
kFeatureEnd}; // Sentinel: always keep last. Used to iterate through types.
/** @return Human-readable name for @p type (e.g. `"ORB"`, `"GFTT+BRIEF"`). */
static std::string typeName(Type type)
@@ -184,6 +185,8 @@ public:
return "GFTT+Daisy";
case kFeatureSurfDaisy:
return "SURF+Daisy";
case kFeaturePyDetector:
return "PyDetector";
case kFeatureSuperPointRpautrat:
return "SUPERPOINT-RPAUTRAT";
default:
@@ -196,6 +199,9 @@ public:
/** @brief Creates a detector of the given @p type. Caller owns the pointer. */
static Feature2D * create(Feature2D::Type type, const ParametersMap & parameters = ParametersMap());
/** @brief Returns true if @p type is available (RTAB-Map is built with it). */
static bool isAvailable(Feature2D::Type type);
/** @brief Keeps keypoints whose depth at (u,v) is in (@p minDepth, @p maxDepth). */
static void filterKeypointsByDepth(
std::vector<cv::KeyPoint> & keypoints,
+78 -1
View File
@@ -614,6 +614,69 @@ Feature2D * Feature2D::create(const ParametersMap & parameters)
Parameters::parse(parameters, Parameters::kKpDetectorStrategy(), type);
return create((Feature2D::Type)type, parameters);
}
bool Feature2D::isAvailable(Feature2D::Type type)
{
// kFeatureUndef is a sentinel ("strategy not specified"); create() falls
// through to a default backend, so the type isn't really "available" as
// requested.
if(type == kFeatureUndef)
{
return false;
}
// SURF / SIFT / SURF-FREAK / SURF-DAISY require either OpenCV < 3.4.11
// (built-in) OR the xfeatures2d module + RTABMAP_NONFREE for OpenCV >= 3.4.11.
#if CV_MAJOR_VERSION < 3 || (CV_MAJOR_VERSION == 4 && CV_MINOR_VERSION <= 3) || (CV_MAJOR_VERSION == 3 && (CV_MINOR_VERSION < 4 || (CV_MINOR_VERSION==4 && CV_SUBMINOR_VERSION<11)))
#ifndef RTABMAP_NONFREE
if(type == kFeatureSurf || type == kFeatureSift || type == kFeatureSurfFreak || type == kFeatureSurfDaisy)
{
return false;
}
#endif
#else
#ifndef RTABMAP_NONFREE
if(type == kFeatureSurf || type == kFeatureSurfFreak || type == kFeatureSurfDaisy)
{
return false;
}
#endif
#endif
#if !defined(HAVE_OPENCV_XFEATURES2D) && CV_MAJOR_VERSION >= 3
if(type == kFeatureFastBrief ||
type == kFeatureFastFreak ||
type == kFeatureGfttBrief ||
type == kFeatureGfttFreak ||
type == kFeatureSurfFreak ||
type == kFeatureGfttDaisy ||
type == kFeatureSurfDaisy)
{
return false;
}
#elif CV_MAJOR_VERSION < 3
if(type == kFeatureKaze ||
type == kFeatureGfttDaisy ||
type == kFeatureSurfDaisy)
{
return false;
}
#endif
#ifndef RTABMAP_ORB_OCTREE
if(type == kFeatureOrbOctree) return false;
#endif
#ifndef RTABMAP_TORCH
if(type == kFeatureSuperPointTorch) return false;
#endif
#if !defined(RTABMAP_TORCH) || !defined(RTABMAP_PYTHON)
if(type == kFeatureSuperPointRpautrat) return false;
#endif
#ifndef RTABMAP_PYTHON
if(type == kFeaturePyDetector) return false;
#endif
return true;
}
Feature2D * Feature2D::create(Feature2D::Type type, const ParametersMap & parameters)
{
@@ -2173,7 +2236,21 @@ std::vector<cv::KeyPoint> GFTT::generateKeypointsImpl(const cv::Mat & image, con
{
_gftt->detect(imgRoi, keypoints, maskRoi); // Opencv keypoints
}
if(!_useHarrisDetector && _qualityLevel>0.0)
{
std::vector<cv::KeyPoint> bestKeypoints;
bestKeypoints.reserve(keypoints.size());
for(size_t i=0; i<keypoints.size(); ++i)
{
if(keypoints[i].response > _qualityLevel)
{
bestKeypoints.push_back(keypoints[i]);
}
}
return bestKeypoints;
}
return keypoints;
}
+42 -9
View File
@@ -1,13 +1,46 @@
"""Trace MagicLeap's SuperPoint pretrained net to TorchScript for rtabmap.
Usage:
python rtabmap_trace_superpoint.py [--weights superpoint_v1.pth]
[--output superpoint_v1.pt]
[--model-dir <dir containing demo_superpoint.py>]
`--model-dir` is prepended to sys.path so `from demo_superpoint import ...`
resolves. Defaults to the directory of `--weights` (or cwd).
"""
import argparse
import os
import sys
from pathlib import Path
import torch
import torchvision
from demo_superpoint import SuperPointNet
model = SuperPointNet()
model.load_state_dict(torch.load("superpoint_v1.pth"))
model.eval()
example = torch.rand(1, 1, 640, 480)
traced_script_module = torch.jit.trace(model, example, check_trace=False)
traced_script_module.save("superpoint_v1.pt")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--weights", default="superpoint_v1.pth")
parser.add_argument("--output", default="superpoint_v1.pt")
parser.add_argument("--model-dir", default=None,
help="directory containing demo_superpoint.py (default: dir of --weights, then cwd)")
args = parser.parse_args()
candidates = [args.model_dir, os.path.dirname(os.path.abspath(args.weights)), os.getcwd()]
for path in candidates:
if path and path not in sys.path:
sys.path.insert(0, path)
from demo_superpoint import SuperPointNet # noqa: E402
model = SuperPointNet()
model.load_state_dict(torch.load(args.weights, map_location="cpu"))
model.eval()
example = torch.rand(1, 1, 640, 480)
traced_script_module = torch.jit.trace(model, example, check_trace=False)
os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
traced_script_module.save(args.output)
print(f"Saved TorchScript: {args.output}")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -48,7 +48,7 @@ static std::string exportSuperPointTorchScript(
const std::string weightsPath = superpointWeightsPath;
const std::string modelPath = superpointModelPath;
const std::string output = std::string(outputDir + "/superpoint_v6_from_tf.pt");
// Sanity checks
if(!UFile::exists(weightsPath)) {
UERROR("Weights not found: %s", weightsPath.c_str());
@@ -5,10 +5,19 @@ Convert PyTorch weights to TorchScript format for C++ usage.
import argparse
import os
import sys
import warnings
import torch
import torch.nn as nn
from superpoint_pytorch import SuperPoint
# rpautrat's superpoint_pytorch.py uses Python control flow on tensor shapes
# (`if image.shape[1] == 3`, `if b > 1`, ...) and arithmetic on shape ints
# (`keypoints.new_tensor([w, h])`). torch.jit.trace warns on those because
# the resulting TorchScript only generalises to inputs with the same shape.
# That's exactly our use case (single grayscale image, fixed batch=1), so
# silence the warnings to keep the C++ trace output clean.
warnings.filterwarnings("ignore", category=torch.jit.TracerWarning)
def wrap_model(model: nn.Module):
@@ -56,6 +65,10 @@ def generate_model(
device = "cuda" if cuda else "cpu"
# Imported lazily so callers that pre-set sys.path via --model-dir don't
# need superpoint_pytorch on PYTHONPATH at module import time.
from superpoint_pytorch import SuperPoint # noqa: E402
# Load SuperPoint model and weights
model = SuperPoint(
nms_radius=nms_radius,
@@ -77,8 +90,8 @@ def generate_model(
scripted = torch.jit.trace(wrapped, (dummy,), strict=False)
print("Successfully traced SuperPoint model")
# Save output
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Save output (handle bare filenames where dirname == "")
os.makedirs(os.path.dirname(os.path.abspath(output_path)) or ".", exist_ok=True)
torch.jit.save(scripted, output_path)
print(f"Converted SuperPoint weights to TorchScript: {output_path}")
@@ -88,6 +101,8 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Convert SuperPoint weights to TorchScript")
parser.add_argument("--weights", required=True, help="Path to weights file")
parser.add_argument("--output", required=True, help="Output TorchScript file")
parser.add_argument("--model-dir", default=None,
help="directory containing superpoint_pytorch.py (default: dir of --weights, then cwd)")
parser.add_argument("--cuda", action="store_true", help="Use CUDA")
parser.add_argument("--width", type=int, default=1920, help="Width of the input image")
parser.add_argument("--height", type=int, default=288, help="Height of the input image")
@@ -95,7 +110,15 @@ if __name__ == "__main__":
parser.add_argument("--threshold", type=float, default=0.005, help="Confidence threshold")
args = parser.parse_args()
print(f"Generating model from weights: {args.weights} to output: {args.output}")
# Make superpoint_pytorch.py discoverable to the lazy import in
# generate_model(). Default to the weights' directory so a co-located
# model file Just Works in the fetch_test_data.sh flow.
candidates = [args.model_dir, os.path.dirname(os.path.abspath(args.weights)), os.getcwd()]
for path in candidates:
if path and path not in sys.path:
sys.path.insert(0, path)
generate_model(
weights_path=args.weights,
output_path=args.output,
+65 -32
View File
@@ -62,11 +62,30 @@ TEST(Feature2DTest, TypeNameKnownTypes)
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeatureSurfFreak), "SURF+Freak");
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeatureGfttDaisy), "GFTT+Daisy");
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeatureSurfDaisy), "SURF+Daisy");
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeaturePyDetector), "Unknown");
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeaturePyDetector), "PyDetector");
EXPECT_EQ(Feature2D::typeName(Feature2D::kFeatureSuperPointRpautrat), "SUPERPOINT-RPAUTRAT");
EXPECT_EQ(Feature2D::typeName((Feature2D::Type)99), "Unknown");
}
// Invariant: every type in [kFeatureSurf, kFeatureEnd) is named explicitly in
// the typeName() switch. If a new value is appended to Feature2D::Type
// without adding a matching `case`, it falls through to the "Unknown"
// default and this test fails -- forcing the new backend to ship with a
// label like every other strategy.
TEST(Feature2DTest, TypeNameCoverage)
{
for(int strategy = Feature2D::kFeatureSurf; strategy < Feature2D::kFeatureEnd; ++strategy)
{
const Feature2D::Type t = static_cast<Feature2D::Type>(strategy);
const std::string name = Feature2D::typeName(t);
EXPECT_NE(name, "Unknown")
<< "Feature2D::typeName() returns \"Unknown\" for enum value "
<< strategy << " -- add a `case` for it in the switch.";
EXPECT_FALSE(name.empty())
<< "Feature2D::typeName() returns empty string for enum value " << strategy;
}
}
TEST(Feature2DTest, ComputeRoiFromRatios)
{
const cv::Mat image = checkerboardImage(100, 100);
@@ -189,42 +208,23 @@ TEST(Feature2DTest, CreateOrbDetector)
// Smoke test: create() must not crash or return null for every strategy (fallbacks allowed).
TEST(Feature2DTest, CreateAllDetectorStrategiesSmoke)
{
const Feature2D::Type strategies[] = {
Feature2D::kFeatureUndef,
Feature2D::kFeatureSurf,
Feature2D::kFeatureSift,
Feature2D::kFeatureOrb,
Feature2D::kFeatureFastFreak,
Feature2D::kFeatureFastBrief,
Feature2D::kFeatureGfttFreak,
Feature2D::kFeatureGfttBrief,
Feature2D::kFeatureBrisk,
Feature2D::kFeatureGfttOrb,
Feature2D::kFeatureKaze,
Feature2D::kFeatureOrbOctree,
Feature2D::kFeatureSuperPointTorch,
Feature2D::kFeatureSurfFreak,
Feature2D::kFeatureGfttDaisy,
Feature2D::kFeatureSurfDaisy,
Feature2D::kFeaturePyDetector,
Feature2D::kFeatureSuperPointRpautrat,
};
const ParametersMap params = orbTestParams();
for(size_t i = 0; i < sizeof(strategies) / sizeof(strategies[0]); ++i)
// kFeatureUndef passes through create()'s default branch; cover it too.
{
const Feature2D::Type requested = strategies[i];
std::unique_ptr<Feature2D> detector(Feature2D::create(requested, params));
ASSERT_TRUE(detector.get() != NULL)
<< "create() returned null for " << Feature2D::typeName(requested);
std::unique_ptr<Feature2D> detector(Feature2D::create(Feature2D::kFeatureUndef));
ASSERT_TRUE(detector.get() != NULL) << "create() returned null for kFeatureUndef";
}
for(int strategy = Feature2D::kFeatureSurf; strategy <= Feature2D::kFeatureSuperPointRpautrat; ++strategy)
for(int strategy = Feature2D::kFeatureSurf; strategy < Feature2D::kFeatureEnd; ++strategy)
{
ParametersMap paramsWithStrategy = params;
paramsWithStrategy[Parameters::kKpDetectorStrategy()] = uNumber2Str(strategy);
std::unique_ptr<Feature2D> detector(Feature2D::create(paramsWithStrategy));
const Feature2D::Type requested = static_cast<Feature2D::Type>(strategy);
std::unique_ptr<Feature2D> detector(Feature2D::create(requested));
ASSERT_TRUE(detector.get() != NULL)
<< "create() returned null for " << Feature2D::typeName(requested);
ParametersMap paramsWithStrategy;
paramsWithStrategy[Parameters::kKpDetectorStrategy()] = uNumber2Str(strategy);
std::unique_ptr<Feature2D> detectorFromParams(Feature2D::create(paramsWithStrategy));
ASSERT_TRUE(detectorFromParams.get() != NULL)
<< "create(ParametersMap) returned null for Kp/DetectorStrategy=" << strategy;
}
}
@@ -238,6 +238,39 @@ TEST(Feature2DTest, CreateFromParametersMap)
EXPECT_EQ(detector->getType(), Feature2D::kFeatureOrb);
}
// Invariant: Feature2D::isAvailable(T) is the negation of "create() would
// silently substitute a different backend for T in this build". If anyone
// adds a new build-flag fallback in Feature2D::create() without updating
// isAvailable() (or vice versa), this test fails -- which is the whole point.
// Iterates [kFeatureSurf, kFeatureEnd) so a new backend appended to the enum
// is auto-covered without an extra edit here.
TEST(Feature2DTest, IsAvailableMatchesCreate)
{
for(int strategy = Feature2D::kFeatureSurf; strategy < Feature2D::kFeatureEnd; ++strategy)
{
const Feature2D::Type requested = static_cast<Feature2D::Type>(strategy);
std::unique_ptr<Feature2D> detector(Feature2D::create(requested));
ASSERT_TRUE(detector.get() != NULL)
<< Feature2D::typeName(requested) << ": create() returned null";
const bool available = Feature2D::isAvailable(requested);
const bool matched = detector->getType() == requested;
EXPECT_EQ(available, matched)
<< Feature2D::typeName(requested)
<< ": isAvailable()=" << available
<< " but create()->getType()="
<< Feature2D::typeName(detector->getType())
<< " (matched=" << matched << "). "
<< "Update Feature2D::isAvailable() to match the new "
<< "fallback in Feature2D::create() (or vice versa).";
}
// kFeatureUndef is a sentinel ("strategy not specified"). create() falls
// through to a default backend (SURF or GFTT_ORB depending on
// RTABMAP_NONFREE), so isAvailable() must report it as unavailable.
EXPECT_FALSE(Feature2D::isAvailable(Feature2D::kFeatureUndef))
<< "kFeatureUndef should never be reported as available";
}
TEST(Feature2DTest, ParseParametersUpdatesMaxFeatures)
{
ParametersMap params = orbTestParams();
+327
View File
@@ -15,6 +15,8 @@
#include <gtest/gtest.h>
#include <rtabmap/core/DBReader.h>
#include <rtabmap/core/Features2d.h>
#include <rtabmap/core/camera/CameraImages.h>
#include <rtabmap/core/Graph.h>
#include <rtabmap/core/LocalGrid.h>
#include <rtabmap/core/OccupancyGrid.h>
@@ -38,6 +40,8 @@
#include <rtabmap/utilite/ULogger.h>
#include "TestUtils.h"
#include <opencv2/imgcodecs.hpp>
#include <algorithm>
#include <iostream>
#include <memory>
#include <string>
@@ -1524,3 +1528,326 @@ TEST_F(RtabmapIntegrationFixture, Loop3ItGps)
<< v.label << " produced an unexpectedly small graph";
}
}
// ---------------------------------------------------------------------------
// Appearance-only loop closure on the 84-image `data/samples` set with the
// shipped `data/samples_GT.bmp` ground truth. Measures recall at 100%
// precision (the rtabmap "max recall while no false positive has appeared
// yet" metric — same definition as the legacy MATLAB getPrecisionRecall.m
// script) for every Features2D detector strategy that is available in this
// build. Detector strategies for which Feature2D::create() silently
// substitutes a different backend (e.g. SURF -> SIFT without nonfree,
// SuperPointTorch -> GFTT/ORB without RTABMAP_TORCH) are skipped.
// ---------------------------------------------------------------------------
TEST_F(RtabmapIntegrationFixture, AppearanceOnly_PrecisionRecall)
{
const std::string samplesDir = std::string(RTABMAP_TEST_DATA_ROOT) + "/samples";
const std::string gtPath = std::string(RTABMAP_TEST_DATA_ROOT) + "/samples_GT.bmp";
SKIP_IF_MISSING(samplesDir);
SKIP_IF_MISSING(gtPath);
// 84x84 binary loop-closure ground truth. Pixel (i, j) == 255 means
// query frame i+1 has a true loop with past frame j+1. The
// gray-pixel "ignore" zone the MATLAB script handles is not present
// in this GT (only 0 / 255), so we skip that branch.
cv::Mat gt = cv::imread(gtPath, cv::IMREAD_GRAYSCALE);
ASSERT_EQ(84, gt.rows) << "unexpected samples_GT.bmp size";
ASSERT_EQ(84, gt.cols) << "unexpected samples_GT.bmp size";
const int kNumFrames = 84;
// Total GT positives = number of query rows with at least one true
// loop. Recall denominator in the standard rtabmap metric.
int gtTotalPositives = 0;
for(int i = 0; i < gt.rows; ++i)
{
for(int j = 0; j < gt.cols; ++j)
{
if(gt.at<unsigned char>(i, j) == 255)
{
++gtTotalPositives;
break;
}
}
}
ASSERT_GT(gtTotalPositives, 0) << "samples_GT.bmp has no positives";
// Iterate every backend listed in Feature2D::Type. kFeatureEnd is the
// sentinel kept at the back of the enum so a new strategy gets covered
// here automatically.
// SuperPoint asset paths. SuperPointTorch needs a pre-traced *.pt
// (produced by scripts/fetch_test_data.sh from *.pth when torch is
// installed). The Rpautrat backend takes the *.pth directly and traces
// to *.pt at runtime inside the C++ class, so we point it at the .pth
// + the python model file the runtime tracer needs.
const std::string superpointTorchModel = std::string(RTABMAP_TEST_DATA_ROOT) + "/tests/superpoint_v1.pt";
const std::string superpointRpautratWeights = std::string(RTABMAP_TEST_DATA_ROOT) + "/tests/superpoint_v6_from_tf.pth";
const std::string superpointRpautratModel = std::string(RTABMAP_TEST_DATA_ROOT) + "/tests/superpoint_pytorch.py";
// Two BoW likelihood variants: the rtabmap default (raw word-count
// likelihood) and the TF-IDF-weighted variant. They produce different
// hypothesis distributions, so each detector is exercised under both.
const std::vector<bool> tfIdfVariants = {false, true};
int detectorsTested = 0;
for(bool tfIdfUsed : tfIdfVariants)
for(int strategy = Feature2D::kFeatureSurf; strategy < Feature2D::kFeatureEnd; ++strategy)
{
const Feature2D::Type detectorType = static_cast<Feature2D::Type>(strategy);
// Label includes the variant so per-detector logs / output BMPs /
// work DBs don't clobber each other across the two iterations.
const std::string detectorLabel = Feature2D::typeName(detectorType)
+ (tfIdfUsed ? "[TfIdf]" : "[Likelihood]");
if(!Feature2D::isAvailable(detectorType))
{
std::cerr << "[skip] detector " << detectorLabel << " not available in this build\n";
continue;
}
if(detectorType == Feature2D::kFeaturePyDetector)
{
std::cerr << "[skip] detector " << detectorLabel
<< " requires a user-supplied Py/DetectorPath script\n";
continue;
}
// SuperPoint variants need traced *.pt weights (plus a Python
// model file for the Rpautrat backend). The fetch_test_data.sh
// script writes them under data/tests/; skip cleanly if absent.
if(detectorType == Feature2D::kFeatureSuperPointTorch && !UFile::exists(superpointTorchModel))
{
std::cerr << "[skip] detector " << detectorLabel
<< " missing weights: " << superpointTorchModel
<< " (run scripts/fetch_test_data.sh)\n";
continue;
}
if(detectorType == Feature2D::kFeatureSuperPointRpautrat &&
(!UFile::exists(superpointRpautratWeights) ||
!UFile::exists(superpointRpautratModel)))
{
std::cerr << "[skip] detector " << detectorLabel
<< " missing assets: weights=" << superpointRpautratWeights
<< " model=" << superpointRpautratModel
<< " (run scripts/fetch_test_data.sh)\n";
continue;
}
SCOPED_TRACE(std::string("detector=") + detectorLabel);
ParametersMap params;
params[Parameters::kRGBDEnabled()] = "false";
params[Parameters::kKpDetectorStrategy()] = uNumber2Str(static_cast<int>(detectorType));
params[Parameters::kSURFHessianThreshold()] = "150";
params[Parameters::kMemSTMSize()] = "20";
params[Parameters::kKpTfIdfLikelihoodUsed()] = tfIdfUsed ? "true" : "false";
params[Parameters::kKpMaxFeatures()] = "400";
params[Parameters::kKpBadSignRatio()] = "0.1";
// SIFT-specific: lower the contrast threshold so more keypoints
// survive on the low-texture frames in data/samples.
params[Parameters::kSIFTContrastThreshold()] = "0.01";
// BRISK-specific: lower FAST threshold so the detector keeps more
// candidates per frame; default is too strict for this dataset.
params[Parameters::kBRISKThresh()] = "10";
// KAZE-specific: drop the response threshold an order of magnitude
// so more (weaker) keypoints survive on the low-texture frames.
params[Parameters::kKAZEThreshold()] = "0.0001";
// GFTT-specific: tighten the minimum keypoint separation (default
// 7 px) so more candidates fit per frame.
params[Parameters::kGFTTMinDistance()] = "5";
params[Parameters::kMemBadSignaturesIgnored()] = "true";
params[Parameters::kMemRehearsalSimilarity()] = "0.20";
// Backend-specific asset paths.
if(detectorType == Feature2D::kFeatureSuperPointTorch)
{
params[Parameters::kSuperPointModelPath()] = superpointTorchModel;
params[Parameters::kSuperPointCuda()] = "false";
}
else if(detectorType == Feature2D::kFeatureSuperPointRpautrat)
{
params[Parameters::kSuperPointRpautratWeightsPath()] = superpointRpautratWeights;
params[Parameters::kSuperPointRpautratModelPath()] = superpointRpautratModel;
params[Parameters::kSuperPointRpautratCuda()] = "false";
}
const std::string workDb = workDbForCurrentTest(detectorLabel);
UFile::erase(workDb);
Rtabmap rtabmap;
rtabmap.init(params, workDb);
struct FrameStat {
int queryRow; // 0-based query frame index
double hypValue; // rtabmap.getHighestHypothesisValue()
int hypId; // rtabmap.getHighestHypothesisId() (1-based, 0 = none)
bool accepted; // rtabmap.getLoopClosureId() > 0
bool correct; // GT[queryRow][hypId-1] == 255
bool gtPositive; // any GT[queryRow][*] == 255
};
std::vector<FrameStat> stats;
stats.reserve(kNumFrames);
CameraImages camera(samplesDir);
ASSERT_TRUE(camera.init()) << "CameraImages.init() failed on " << samplesDir;
UTimer wall;
int i = 0;
SensorData data = camera.takeImage();
while(!data.imageRaw().empty())
{
++i;
data.setId(i);
data.setStamp(static_cast<double>(i));
const bool ok = rtabmap.process(data, Transform());
ASSERT_TRUE(ok) << detectorLabel << " rtabmap.process failed at frame " << i;
FrameStat s;
s.queryRow = i - 1;
s.hypValue = rtabmap.getHighestHypothesisValue();
s.hypId = rtabmap.getHighestHypothesisId();
s.accepted = rtabmap.getLoopClosureId() > 0;
s.gtPositive = false;
for(int j = 0; j < gt.cols; ++j)
{
if(gt.at<unsigned char>(s.queryRow, j) == 255)
{
s.gtPositive = true;
break;
}
}
s.correct = false;
if(s.hypId > 0 && s.hypId - 1 < gt.cols)
{
s.correct = gt.at<unsigned char>(s.queryRow, s.hypId - 1) == 255;
}
stats.push_back(s);
data = camera.takeImage();
}
ASSERT_EQ(kNumFrames, i)
<< detectorLabel << " expected " << kNumFrames
<< " frames from " << samplesDir << ", got " << i;
rtabmap.close();
// Standard rtabmap P/R curve: sort frames by hypothesis value
// descending and walk down; precision = correct hypotheses so far
// divided by total hypotheses so far, recall = correct so far over
// gtTotalPositives. "Recall at 100% precision" is the recall at
// the last point before the first FP appears.
std::vector<FrameStat> sorted = stats;
std::sort(sorted.begin(), sorted.end(),
[](const FrameStat & a, const FrameStat & b){
return a.hypValue > b.hypValue;
});
int tp = 0, fp = 0;
float recallAt100p = 0.0f;
float thrAt100p = 0.0f;
bool seenFp = false;
for(const FrameStat & s : sorted)
{
if(s.hypId <= 0 || s.hypValue <= 0.0)
{
continue; // no hypothesis at all this frame
}
if(s.correct)
{
++tp;
if(!seenFp)
{
recallAt100p = float(tp) / float(gtTotalPositives);
thrAt100p = static_cast<float>(s.hypValue);
}
}
else
{
if(!seenFp)
{
// One-shot diagnostic: the first FP is what gates
// recall@100%P. Print the (query, matched) pair so we
// can eyeball whether it's a true mismatch or just a
// visually-similar frame the GT happens not to flag.
std::cerr << "[" << detectorLabel << "] first-FP query="
<< (s.queryRow + 1)
<< " matched=" << s.hypId
<< " hypValue=" << s.hypValue
<< " (TPs above this point=" << tp << ")\n";
}
++fp;
seenFp = true;
}
}
// Also report end-of-run precision/recall at the default rtabmap
// loop threshold (i.e. counting only accepted closures) -- closer
// to what a real deployment would observe.
int acceptedTp = 0, acceptedFp = 0, acceptedFn = 0;
for(const FrameStat & s : stats)
{
if(s.accepted && s.correct) ++acceptedTp;
else if(s.accepted && !s.correct) ++acceptedFp;
else if(s.gtPositive) ++acceptedFn;
}
const float acceptedPrec = (acceptedTp + acceptedFp) > 0
? float(acceptedTp) / float(acceptedTp + acceptedFp) : 0.0f;
const float acceptedRec = gtTotalPositives > 0
? float(acceptedTp) / float(gtTotalPositives) : 0.0f;
// Dump the accepted loops as a 84x84 binary matrix in the same
// shape as samples_GT.bmp (pixel (query, loop) = 255 when rtabmap
// accepted that closure) so the run is easy to diff visually
// against the ground truth.
cv::Mat detectionsMat = cv::Mat::zeros(kNumFrames, kNumFrames, CV_8UC1);
for(const FrameStat & s : stats)
{
if(s.accepted && s.hypId > 0
&& s.hypId - 1 < detectionsMat.cols
&& s.queryRow < detectionsMat.rows)
{
detectionsMat.at<unsigned char>(s.queryRow, s.hypId - 1) = 255;
}
}
std::string safeLabel = detectorLabel;
for(char & c : safeLabel)
{
if(c == '+' || c == '/' || c == ' ' || c == '\\'
|| c == '[' || c == ']') c = '_';
}
const std::string detectionsBmp = test::tempPath(uFormat(
"rtabmap_integration_AppearanceOnly_%s_loops.bmp", safeLabel.c_str()));
cv::imwrite(detectionsBmp, detectionsMat);
std::cerr << "[" << detectorLabel << "] loop-closure matrix -> "
<< detectionsBmp << "\n";
std::cerr << "[" << detectorLabel << "]"
<< " gtPos=" << gtTotalPositives
<< " sortedTP=" << tp << " sortedFP=" << fp
<< " recall@100%P=" << recallAt100p << " (thr=" << thrAt100p << ")"
<< " accepted: tp=" << acceptedTp << " fp=" << acceptedFp
<< " prec=" << acceptedPrec << " recall=" << acceptedRec
<< " wall=" << wall.elapsed() << "s\n";
if(acceptedTp + acceptedFp == 0)
{
std::cerr << "[" << detectorLabel << "] note: no loop closure accepted "
"at default threshold (sortedTP=" << tp << ", sortedFP=" << fp
<< ")\n";
}
else if(acceptedPrec < 0.5f)
{
std::cerr << "[" << detectorLabel << "] note: accepted-loop precision "
<< acceptedPrec << " below 0.5 (TP=" << acceptedTp
<< ", FP=" << acceptedFp << ")\n";
}
EXPECT_GE(recallAt100p, 0.9f)
<< detectorLabel << " recall@100%P=" << recallAt100p
<< " is below 0.9 (sortedTP=" << tp << ", sortedFP=" << fp << ")";
++detectorsTested;
}
ASSERT_GT(detectorsTested, 0) << "no Features2D detector was available in this build";
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

+7 -1
View File
@@ -1 +1,7 @@
*.db
# Test data fetched by scripts/fetch_test_data.sh -- never tracked.
# The manifest (basename / source URL / sha256) is the source of truth.
*.db
*.pt
*.pth
*.py
__pycache__/
+4
View File
@@ -18,3 +18,7 @@ pr2_scan2d_rgbd_sample_15s.db 1vlFungKsdbnepgsuPHY-1h-mxdFxkZvI 1d8fd150a7f1d1e3
robust_graph_optimization_stereo.db https://raw.githubusercontent.com/wiki/introlab/rtabmap/doc/Tutorials/RobustGraphOptimization/robust_graph_optimization_stereo.db 247694b5bdb82168ebe88bb6bb5bc31c7142234f8c64567afd93e464418cf02e
loop_3it_gps.db https://raw.githubusercontent.com/wiki/introlab/rtabmap/doc/Tutorials/RobustGraphOptimization/loop_3it_gps.db 7aefeb573107a27b0a7826cb87ea03db495367aa56868b4d2b2543e6cacad3c8
stereo_20Hz.db https://github.com/introlab/rtabmap/releases/download/0.23.1/stereo_20Hz.db 94e219e1c96e540cbb1490cc23c81c65b9e7b132e8d61eda05e7990bc13393b7
superpoint_v1.pth https://github.com/magicleap/SuperPointPretrainedNetwork/raw/refs/heads/master/superpoint_v1.pth 52b6708629640ca883673b5d5c097c4ddad37d8048b33f09c8ca0d69db12c40e
superpoint_v6_from_tf.pth https://github.com/rpautrat/SuperPoint/raw/refs/heads/master/weights/superpoint_v6_from_tf.pth cd5d19a5061848e248c17728878ea166b66512076d43c77dbcf27f4a88a56084
demo_superpoint.py https://raw.githubusercontent.com/magicleap/SuperPointPretrainedNetwork/master/demo_superpoint.py 613706ae7e9ce3fbc2cfe042fc3f37d739838cc2e1dc8ddd08f8ec037765df04
superpoint_pytorch.py https://raw.githubusercontent.com/rpautrat/SuperPoint/master/superpoint_pytorch.py c06e3b0aad7548316ca59db3be9f56c7fcd126a1cf7a73359445fb778598f365
+47
View File
@@ -80,3 +80,50 @@ while IFS=$'\t' read -r name source expected_sha; do
done < "$MANIFEST"
echo "Test data ready under $DEST_DIR"
# --- Optional: trace SuperPoint *.pth -> *.pt for the tests ---
# The C++ side loads TorchScript (*.pt); upstream ships only *.pth, so we
# trace them locally if python3 + torch are on PATH. A failure is logged and
# silently skipped -- the test guards every SuperPoint variant with
# UFile::exists(*.pt) and skips when the trace didn't run.
trace_superpoint_pt() {
local label="$1" script="$2" weights="$3" output="$4" model_dir="$5"
if [[ -f "$output" ]]; then
echo " $label: already traced ($output)"
return 0
fi
if [[ ! -f "$weights" || ! -f "$model_dir/$(basename "$script" | sed 's/^rtabmap_trace_superpoint\.py$/demo_superpoint.py/; s/^superpoint_to_torchscript\.py$/superpoint_pytorch.py/')" ]]; then
echo " $label: source files missing — skipping trace"
return 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo " $label: python3 not on PATH — skipping trace"
return 0
fi
if ! python3 -c "import torch" >/dev/null 2>&1; then
echo " $label: python3 doesn't have torch — skipping trace"
return 0
fi
echo "Tracing $label: $weights -> $output"
# Run in a subshell with PWD in DEST_DIR so demo_superpoint.py /
# superpoint_pytorch.py (also under DEST_DIR) are found by the bare
# `from X import ...` inside the trace scripts.
if ( cd "$model_dir" && python3 "$script" --weights "$weights" --output "$output" ) >/tmp/sp_trace.log 2>&1; then
echo " $label: traced ($(du -h "$output" | cut -f1))"
else
echo " $label: trace failed (see /tmp/sp_trace.log) — test will skip"
rm -f "$output"
fi
}
trace_superpoint_pt \
"superpoint_v1" \
"$REPO_ROOT/corelib/src/python/rtabmap_trace_superpoint.py" \
"$DEST_DIR/superpoint_v1.pth" \
"$DEST_DIR/superpoint_v1.pt" \
"$DEST_DIR"
# rpautrat's SuperPoint backend (kFeatureSuperPointRpautrat) traces its own
# *.pth -> *.pt on first detect() inside the C++ class, so the fetch script
# doesn't pre-trace it. The *.pth and superpoint_pytorch.py downloaded above
# are what that runtime tracer consumes.