mirror of
https://github.com/introlab/rtabmap.git
synced 2026-10-03 16:47:47 +08:00
added appearance-based tests, set min gftt quality to quality level
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 |
@@ -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__/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user