Added python tests

This commit is contained in:
matlabbe
2026-05-27 00:48:11 -07:00
parent f49157b4e2
commit 35dbb4f20a
7 changed files with 788 additions and 3 deletions
+10
View File
@@ -78,6 +78,16 @@ void PyDescriptor::parseParameters(const ParametersMap & parameters)
{ {
return; return;
} }
// Pre-validate the path: PyImport_Import on a non-existent script
// fails without setting a Python exception, after which
// getPythonTraceback() dereferences NULL pointers in Py_BuildValue
// and crashes. Mirrors the check in PyDetector::PyDetector().
if(!UFile::exists(path_) || UFile::getExtension(path_).compare("py") != 0)
{
UERROR("Cannot initialize Python descriptor, the path is not valid: \"%s\"=\"%s\"",
Parameters::kPyDescriptorPath().c_str(), path_.c_str());
return;
}
std::string matcherPythonDir = UDirectory::getDir(path_); std::string matcherPythonDir = UDirectory::getDir(path_);
if(!matcherPythonDir.empty()) if(!matcherPythonDir.empty())
{ {
+28 -3
View File
@@ -65,12 +65,28 @@ std::string getPythonTraceback()
{ {
// Author: https://stackoverflow.com/questions/41268061/c-c-python-exception-traceback-not-being-generated // Author: https://stackoverflow.com/questions/41268061/c-c-python-exception-traceback-not-being-generated
// Early-exit when there is no active Python exception: callers commonly
// log this after any Python C-API failure, but some failures (notably
// PyImport_Import returning NULL after repeated load/unload cycles) do
// not set an exception. Without this guard, PyErr_Fetch returns NULL
// triples and Py_BuildValue("OOO", NULL, NULL, NULL) below crashes.
if(!PyErr_Occurred())
{
return "<no python exception set>";
}
PyObject* type; PyObject* type;
PyObject* value; PyObject* value;
PyObject* traceback; PyObject* traceback;
PyErr_Fetch(&type, &value, &traceback); PyErr_Fetch(&type, &value, &traceback);
PyErr_NormalizeException(&type, &value, &traceback); PyErr_NormalizeException(&type, &value, &traceback);
// PyErr_Fetch may leave value/traceback NULL even when an exception was
// active. Py_BuildValue("OOO", ...) rejects NULL slots and raises
// SystemError, so substitute Py_None for any missing component.
if(!type) { Py_INCREF(Py_None); type = Py_None; }
if(!value) { Py_INCREF(Py_None); value = Py_None; }
if(!traceback) { Py_INCREF(Py_None); traceback = Py_None; }
std::string fcn = ""; std::string fcn = "";
fcn += "def get_pretty_traceback(exc_type, exc_value, exc_tb):\n"; fcn += "def get_pretty_traceback(exc_type, exc_value, exc_tb):\n";
@@ -85,13 +101,22 @@ std::string getPythonTraceback()
UASSERT(mod); UASSERT(mod);
PyObject* method = PyObject_GetAttrString(mod, "get_pretty_traceback"); PyObject* method = PyObject_GetAttrString(mod, "get_pretty_traceback");
UASSERT(method); UASSERT(method);
PyObject* outStr = PyObject_CallObject(method, Py_BuildValue("OOO", type, value, traceback)); PyObject* args = Py_BuildValue("OOO", type, value, traceback);
PyObject* outStr = args ? PyObject_CallObject(method, args) : nullptr;
std::string pretty; std::string pretty;
if(outStr) if(outStr)
pretty = PyBytes_AsString(PyUnicode_AsASCIIString(outStr)); {
PyObject* asciiStr = PyUnicode_AsASCIIString(outStr);
if(asciiStr)
{
pretty = PyBytes_AsString(asciiStr);
Py_DECREF(asciiStr);
}
}
Py_XDECREF(args);
Py_DECREF(method); Py_DECREF(method);
Py_DECREF(outStr); Py_XDECREF(outStr); // outStr may be NULL if PyObject_CallObject failed
Py_DECREF(mod); Py_DECREF(mod);
return pretty; return pretty;
+30
View File
@@ -173,6 +173,36 @@ add_executable(test_localgridmaker test_localgridmaker.cpp)
target_link_libraries(test_localgridmaker gtest_main rtabmap_core) target_link_libraries(test_localgridmaker gtest_main rtabmap_core)
add_test(NAME test_localgridmaker COMMAND test_localgridmaker) add_test(NAME test_localgridmaker COMMAND test_localgridmaker)
#PythonInterface / PyDetector / PyDescriptor / PyMatcher (optional Python
# support; bridges to Python feature detectors (SuperPoint), global
# descriptors (NetVLAD), and matchers (SuperGlue). Tests use numpy-only stub
# scripts so no ML weights or extra packages are needed beyond what
# WITH_PYTHON already pulls in.
IF(WITH_PYTHON AND Python3_FOUND)
# Direct tests for PythonInterface / getPythonTraceback. Links pybind11
# and Python headers so the test can drive the Python C API directly.
add_executable(test_python_interface test_python_interface.cpp)
target_link_libraries(test_python_interface gtest_main rtabmap_core pybind11::embed)
target_include_directories(test_python_interface PRIVATE ${Python3_INCLUDE_DIRS})
add_test(NAME test_python_interface COMMAND test_python_interface)
add_executable(test_pydetector test_pydetector.cpp)
target_link_libraries(test_pydetector gtest_main rtabmap_core)
add_test(NAME test_pydetector COMMAND test_pydetector)
add_executable(test_pydescriptor test_pydescriptor.cpp)
target_link_libraries(test_pydescriptor gtest_main rtabmap_core)
add_test(NAME test_pydescriptor COMMAND test_pydescriptor)
# PyMatcher's header is internal (not in the public include tree);
# expose corelib/src so the test can `#include "python/PyMatcher.h"`.
add_executable(test_pymatcher test_pymatcher.cpp)
target_link_libraries(test_pymatcher gtest_main rtabmap_core)
target_include_directories(test_pymatcher PRIVATE
${CMAKE_SOURCE_DIR}/corelib/src)
add_test(NAME test_pymatcher COMMAND test_pymatcher)
ENDIF(WITH_PYTHON AND Python3_FOUND)
#LASWriter.h (optional libLAS) #LASWriter.h (optional libLAS)
IF(libLAS_FOUND) IF(libLAS_FOUND)
add_executable(test_laswriter test_laswriter.cpp) add_executable(test_laswriter test_laswriter.cpp)
+202
View File
@@ -0,0 +1,202 @@
// Tests for PyDescriptor: rtabmap's bridge to Python-backed global-image
// descriptors (NetVLAD and similar).
//
// We stand in a tiny numpy-only stub script for whatever a real user would
// point PyDescriptor at, so the test exercises the full pipeline (script
// load -> init(dim) -> extract(image) -> descriptor parsing) without pulling
// in heavy ML model weights. The whole file is a no-op when rtabmap is built
// without Python -- the CMakeLists.txt only registers it under
// WITH_PYTHON AND Python3_FOUND, and a defensive #ifdef matches that.
#include <gtest/gtest.h>
#include <rtabmap/core/Version.h>
#ifdef RTABMAP_PYTHON
#include <rtabmap/core/GlobalDescriptor.h>
#include <rtabmap/core/GlobalDescriptorExtractor.h>
#include <rtabmap/core/Parameters.h>
#include <rtabmap/core/SensorData.h>
#include <rtabmap/utilite/UConversion.h>
#include <rtabmap/utilite/UFile.h>
#include <opencv2/core.hpp>
#include <fstream>
#include <memory>
#include <string>
#include <unistd.h>
using namespace rtabmap;
namespace {
// Drops a minimal descriptor script implementing the contract
// PyDescriptor.cpp expects:
// init(descriptorDim) -- called once
// extract(image) -- returns 1xDIM float32, image is an HxWxC uint8 array
// Echoes the dim that init() received plus the image dimensions into the
// first four descriptor cells so the test can pin them. Numpy-only, no heavy
// deps.
//
// Each test uses a unique filename via `tag` so the Python module cache
// reloads fresh content on each TEST().
std::string writeStubScript(int tag)
{
const std::string path = uFormat(
"/tmp/rtabmap_test_pydescriptor_%d_%d.py", getpid(), tag);
std::ofstream out(path);
out <<
"import numpy as np\n"
"\n"
"INITIALIZED = False\n"
"DIM = 0\n"
"\n"
"def init(descriptorDim):\n"
" global INITIALIZED, DIM\n"
" INITIALIZED = True\n"
" DIM = int(descriptorDim)\n"
"\n"
"def extract(image):\n"
" h = image.shape[0]\n"
" w = image.shape[1]\n"
" c = image.shape[2] if image.ndim == 3 else 1\n"
" desc = np.zeros((1, DIM), dtype=np.float32)\n"
" if DIM >= 4:\n"
" desc[0, 0] = float(DIM)\n"
" desc[0, 1] = float(h)\n"
" desc[0, 2] = float(w)\n"
" desc[0, 3] = float(c)\n"
" return desc\n";
return path;
}
ParametersMap baseParams(const std::string & scriptPath, int dim)
{
ParametersMap p;
p.insert(ParametersPair(Parameters::kPyDescriptorPath(), scriptPath));
p.insert(ParametersPair(Parameters::kPyDescriptorDim(), uNumber2Str(dim)));
return p;
}
cv::Mat makeImage(int rows = 32, int cols = 48)
{
// PyDescriptor passes the raw image as HxWxC uint8; CV_8UC3 mirrors what
// a real RGB camera would supply.
return cv::Mat(rows, cols, CV_8UC3, cv::Scalar(0, 0, 0));
}
} // namespace
// Full happy path: script loads, init() receives the configured dim, extract
// returns a 1xDIM float descriptor whose first cells echo DIM + image shape.
TEST(PyDescriptor, BasicExtraction)
{
const std::string scriptPath = writeStubScript(1);
const int dim = 16;
std::unique_ptr<GlobalDescriptorExtractor> extractor(
GlobalDescriptorExtractor::create(
GlobalDescriptorExtractor::kPyDescriptor,
baseParams(scriptPath, dim)));
ASSERT_NE(extractor.get(), nullptr);
EXPECT_EQ(GlobalDescriptorExtractor::kPyDescriptor, extractor->getType());
cv::Mat image = makeImage(32, 48);
SensorData data(image);
GlobalDescriptor descriptor = extractor->extract(data);
// type=1 matches kPyDescriptor (set inside PyDescriptor::extract).
EXPECT_EQ(1, descriptor.type());
ASSERT_EQ(1, descriptor.data().rows);
ASSERT_EQ(dim, descriptor.data().cols);
EXPECT_EQ(CV_32FC1, descriptor.data().type());
// Stub echoes (DIM, H, W, C) in cells [0..3].
EXPECT_NEAR(float(dim), descriptor.data().at<float>(0, 0), 1e-5f);
EXPECT_NEAR(32.0f, descriptor.data().at<float>(0, 1), 1e-5f);
EXPECT_NEAR(48.0f, descriptor.data().at<float>(0, 2), 1e-5f);
EXPECT_NEAR(3.0f, descriptor.data().at<float>(0, 3), 1e-5f);
// Trailing cells are zero-initialized.
for(int j = 4; j < dim; ++j)
{
EXPECT_NEAR(0.0f, descriptor.data().at<float>(0, j), 1e-5f);
}
UFile::erase(scriptPath);
}
// Changing Kp/PyDescriptor/Dim must reach init() in the script so its returned
// descriptor width follows. We construct with one dim, then check the stub
// echoes that value back.
TEST(PyDescriptor, DimParameterReachesScript)
{
const std::string scriptPath = writeStubScript(2);
const int dim = 8;
std::unique_ptr<GlobalDescriptorExtractor> extractor(
GlobalDescriptorExtractor::create(
GlobalDescriptorExtractor::kPyDescriptor,
baseParams(scriptPath, dim)));
ASSERT_NE(extractor.get(), nullptr);
GlobalDescriptor descriptor = extractor->extract(SensorData(makeImage()));
ASSERT_EQ(dim, descriptor.data().cols);
EXPECT_NEAR(float(dim), descriptor.data().at<float>(0, 0), 1e-5f);
UFile::erase(scriptPath);
}
// When no script path is configured (empty string), PyDescriptor early-exits
// in parseParameters() with a null module. extract() then returns an empty
// descriptor.
TEST(PyDescriptor, EmptyPathReturnsEmpty)
{
std::unique_ptr<GlobalDescriptorExtractor> extractor(
GlobalDescriptorExtractor::create(
GlobalDescriptorExtractor::kPyDescriptor,
baseParams(/*scriptPath=*/"", 16)));
ASSERT_NE(extractor.get(), nullptr);
GlobalDescriptor descriptor = extractor->extract(SensorData(makeImage()));
// Default-constructed: type==-1, data empty.
EXPECT_EQ(-1, descriptor.type());
EXPECT_TRUE(descriptor.data().empty());
}
// A non-existent script path is now pre-validated in parseParameters() (it
// used to crash inside getPythonTraceback when PyImport_Import returned NULL
// without setting an exception). extractor stays valid and extract returns
// an empty descriptor.
TEST(PyDescriptor, MissingPathReturnsEmpty)
{
const std::string scriptPath = uFormat(
"/tmp/rtabmap_test_pydescriptor_does_not_exist_%d.py", getpid());
std::unique_ptr<GlobalDescriptorExtractor> extractor(
GlobalDescriptorExtractor::create(
GlobalDescriptorExtractor::kPyDescriptor,
baseParams(scriptPath, 16)));
ASSERT_NE(extractor.get(), nullptr);
GlobalDescriptor descriptor = extractor->extract(SensorData(makeImage()));
EXPECT_EQ(-1, descriptor.type());
EXPECT_TRUE(descriptor.data().empty());
}
// extract() requires a non-empty raw image; when given an empty SensorData
// it logs an error and returns an empty descriptor.
TEST(PyDescriptor, EmptyImageReturnsEmpty)
{
const std::string scriptPath = writeStubScript(3);
std::unique_ptr<GlobalDescriptorExtractor> extractor(
GlobalDescriptorExtractor::create(
GlobalDescriptorExtractor::kPyDescriptor,
baseParams(scriptPath, 16)));
ASSERT_NE(extractor.get(), nullptr);
GlobalDescriptor descriptor = extractor->extract(SensorData());
EXPECT_EQ(-1, descriptor.type());
EXPECT_TRUE(descriptor.data().empty());
UFile::erase(scriptPath);
}
#endif // RTABMAP_PYTHON
+182
View File
@@ -0,0 +1,182 @@
// Tests for PyDetector: rtabmap's bridge to Python-backed local-feature
// detectors (SuperPoint, custom networks, etc.).
//
// We stand in a tiny numpy-only stub script for whatever the user would
// normally point PyDetector at, so the test exercises the full pipeline
// (script load -> init() -> detect() -> keypoint/descriptor parsing) without
// pulling in heavy ML model weights. The whole file is a no-op when rtabmap
// is built without Python -- the CMakeLists.txt only registers it when
// WITH_PYTHON AND Python3_FOUND, and a defensive #ifdef matches that.
#include <gtest/gtest.h>
#include <rtabmap/core/Version.h>
#ifdef RTABMAP_PYTHON
#include <rtabmap/core/Features2d.h>
#include <rtabmap/core/Parameters.h>
#include <rtabmap/utilite/UConversion.h>
#include <rtabmap/utilite/UFile.h>
#include <opencv2/core.hpp>
#include <fstream>
#include <memory>
#include <string>
#include <unistd.h>
using namespace rtabmap;
namespace {
// Drops a minimal detector script implementing the contract PyDetector.cpp
// expects:
// init(cuda) -- called once
// detect(imageBuffer) -- returns (Nx3 float32 [x, y, response], NxDIM float32)
// Three hard-coded keypoints at (w/4, h/4), (w/2, h/2), (3w/4, 3h/4) with
// descending responses; descriptor rows filled with (row_index + 1) so the
// test can identify them. Numpy-only, no heavy deps.
//
// Each test uses a unique filename via `tag` so the Python module cache
// reloads fresh content on each TEST().
std::string writeStubScript(int tag)
{
const std::string path = uFormat(
"/tmp/rtabmap_test_pydetector_%d_%d.py", getpid(), tag);
std::ofstream out(path);
out <<
"import numpy as np\n"
"\n"
"INITIALIZED = False\n"
"CUDA_ARG = None\n"
"DESCRIPTOR_DIM = 8\n"
"\n"
"def init(cuda):\n"
" global INITIALIZED, CUDA_ARG\n"
" INITIALIZED = True\n"
" CUDA_ARG = int(cuda)\n"
"\n"
"def detect(image):\n"
" h, w = image.shape\n"
" pts = np.array([\n"
" [w * 0.25, h * 0.25, 0.9],\n"
" [w * 0.50, h * 0.50, 0.8],\n"
" [w * 0.75, h * 0.75, 0.7],\n"
" ], dtype=np.float32)\n"
" desc = np.zeros((3, DESCRIPTOR_DIM), dtype=np.float32)\n"
" for i in range(3):\n"
" desc[i, :] = float(i + 1)\n"
" return pts, desc\n";
return path;
}
ParametersMap baseParams(const std::string & scriptPath)
{
ParametersMap p;
p.insert(ParametersPair(Parameters::kPyDetectorPath(), scriptPath));
p.insert(ParametersPair(Parameters::kPyDetectorCuda(), "false"));
p.insert(ParametersPair(Parameters::kKpMaxFeatures(), "100"));
p.insert(ParametersPair(Parameters::kKpSSC(), "false"));
return p;
}
cv::Mat makeImage(int rows = 64, int cols = 64)
{
// PyDetector requires CV_8UC1; uniform content is fine for the stub
// which ignores pixel values.
return cv::Mat(rows, cols, CV_8UC1, cv::Scalar(0));
}
} // namespace
// Full happy path: the script loads, returns 3 keypoints + descriptors, and
// the keypoint positions, responses, and descriptor rows survive end to end.
TEST(PyDetector, BasicDetection)
{
const std::string scriptPath = writeStubScript(1);
std::unique_ptr<Feature2D> detector(Feature2D::create(
Feature2D::kFeaturePyDetector, baseParams(scriptPath)));
ASSERT_NE(detector.get(), nullptr);
EXPECT_EQ(Feature2D::kFeaturePyDetector, detector->getType());
cv::Mat image = makeImage(64, 64);
std::vector<cv::KeyPoint> kpts = detector->generateKeypoints(image);
ASSERT_EQ(3u, kpts.size());
// Stub points: (w/4, h/4), (w/2, h/2), (3w/4, 3h/4) with responses
// 0.9, 0.8, 0.7.
EXPECT_NEAR(16.0f, kpts[0].pt.x, 1e-3f);
EXPECT_NEAR(16.0f, kpts[0].pt.y, 1e-3f);
EXPECT_NEAR(32.0f, kpts[1].pt.x, 1e-3f);
EXPECT_NEAR(48.0f, kpts[2].pt.x, 1e-3f);
EXPECT_NEAR(0.9f, kpts[0].response, 1e-5f);
EXPECT_NEAR(0.8f, kpts[1].response, 1e-5f);
EXPECT_NEAR(0.7f, kpts[2].response, 1e-5f);
cv::Mat descriptors = detector->generateDescriptors(image, kpts);
EXPECT_EQ(3, descriptors.rows);
EXPECT_EQ(8, descriptors.cols);
EXPECT_EQ(CV_32FC1, descriptors.type());
for(int r = 0; r < descriptors.rows; ++r)
{
EXPECT_NEAR(float(r + 1), descriptors.at<float>(r, 0), 1e-5f);
}
UFile::erase(scriptPath);
}
// PyDetector logs an error and silently returns no keypoints when the
// script path doesn't exist -- the constructor doesn't throw. Pin that
// contract so callers can safely construct without pre-checking the path.
TEST(PyDetector, MissingPathReturnsEmpty)
{
const std::string scriptPath = uFormat(
"/tmp/rtabmap_test_pydetector_does_not_exist_%d.py", getpid());
std::unique_ptr<Feature2D> detector(Feature2D::create(
Feature2D::kFeaturePyDetector, baseParams(scriptPath)));
ASSERT_NE(detector.get(), nullptr);
cv::Mat image = makeImage(32, 32);
std::vector<cv::KeyPoint> kpts = detector->generateKeypoints(image);
EXPECT_TRUE(kpts.empty());
}
// A non-empty mask removes keypoints whose (x, y) falls on a 0 pixel. The
// stub returns one keypoint at (48, 48); we mask everything from row/col 33
// onward so it gets dropped while the two earlier points survive.
TEST(PyDetector, MaskFiltersKeypoints)
{
const std::string scriptPath = writeStubScript(2);
std::unique_ptr<Feature2D> detector(Feature2D::create(
Feature2D::kFeaturePyDetector, baseParams(scriptPath)));
ASSERT_NE(detector.get(), nullptr);
cv::Mat image = makeImage(64, 64);
cv::Mat mask(image.size(), CV_8UC1, cv::Scalar(255));
mask(cv::Rect(33, 33, mask.cols - 33, mask.rows - 33)).setTo(0);
std::vector<cv::KeyPoint> kpts = detector->generateKeypoints(image, mask);
EXPECT_EQ(2u, kpts.size());
UFile::erase(scriptPath);
}
// Kp/MaxFeatures caps the returned keypoint list inside generateKeypointsImpl
// (PyDetector calls limitKeypoints with its own descriptor matrix at the end).
// The stub returns 3 keypoints; cap at 2 and verify the rest are dropped.
TEST(PyDetector, MaxFeaturesCap)
{
const std::string scriptPath = writeStubScript(3);
ParametersMap p = baseParams(scriptPath);
p[Parameters::kKpMaxFeatures()] = "2";
std::unique_ptr<Feature2D> detector(
Feature2D::create(Feature2D::kFeaturePyDetector, p));
ASSERT_NE(detector.get(), nullptr);
cv::Mat image = makeImage(64, 64);
std::vector<cv::KeyPoint> kpts = detector->generateKeypoints(image);
EXPECT_EQ(2u, kpts.size());
UFile::erase(scriptPath);
}
#endif // RTABMAP_PYTHON
+236
View File
@@ -0,0 +1,236 @@
// Tests for PyMatcher: rtabmap's bridge to Python-backed descriptor matchers
// (SuperGlue, OANet, etc.). PyMatcher is an internal class (not in the public
// include tree), so the test pulls in its private header via the corelib/src
// include path wired up in CMakeLists.txt.
//
// We stand in a tiny numpy-only stub script for what would otherwise be a
// heavy ML model. The whole file is a no-op when rtabmap is built without
// Python -- the CMakeLists.txt only registers it under
// WITH_PYTHON AND Python3_FOUND, and a defensive #ifdef matches that.
#include <gtest/gtest.h>
#include <rtabmap/core/Version.h>
#ifdef RTABMAP_PYTHON
#include "python/PyMatcher.h" // private header (see CMakeLists.txt include dirs)
#include <rtabmap/utilite/UConversion.h>
#include <rtabmap/utilite/UFile.h>
#include <opencv2/core.hpp>
#include <fstream>
#include <memory>
#include <string>
#include <unistd.h>
using namespace rtabmap;
namespace {
// Drops a minimal matcher script implementing the contract PyMatcher.cpp
// expects:
// init(descriptorDim, matchThreshold, iterations, cuda, model) -- called once
// match(kptsFrom, kptsTo, scoresFrom, scoresTo, descriptorsFrom,
// descriptorsTo, imageWidth, imageHeight)
// -> returns Nx2 int32 (queryIdx, trainIdx) pairs.
//
// Our stub returns a perfect 1:1 mapping for min(rowsFrom, rowsTo) rows
// (i -> i), echoing the count so the test can pin both the pipeline plumbing
// and the array-shape parsing in PyMatcher::match. Numpy-only, no heavy deps.
//
// Each test uses a unique filename via `tag` so the Python module cache
// reloads fresh content on each TEST().
std::string writeStubScript(int tag)
{
const std::string path = uFormat(
"/tmp/rtabmap_test_pymatcher_%d_%d.py", getpid(), tag);
std::ofstream out(path);
out <<
"import numpy as np\n"
"\n"
"INITIALIZED = False\n"
"INIT_DIM = 0\n"
"INIT_THRESHOLD = 0.0\n"
"INIT_ITERATIONS = 0\n"
"INIT_CUDA = 0\n"
"INIT_MODEL = ''\n"
"\n"
"def init(descriptorDim, matchThreshold, iterations, cuda, model):\n"
" global INITIALIZED, INIT_DIM, INIT_THRESHOLD, INIT_ITERATIONS\n"
" global INIT_CUDA, INIT_MODEL\n"
" INITIALIZED = True\n"
" INIT_DIM = int(descriptorDim)\n"
" INIT_THRESHOLD = float(matchThreshold)\n"
" INIT_ITERATIONS = int(iterations)\n"
" INIT_CUDA = int(cuda)\n"
" INIT_MODEL = str(model)\n"
"\n"
"def match(kptsFrom, kptsTo, scoresFrom, scoresTo,\n"
" descriptorsFrom, descriptorsTo, imageWidth, imageHeight):\n"
" nFrom = descriptorsFrom.shape[0]\n"
" nTo = descriptorsTo.shape[0]\n"
" n = min(nFrom, nTo)\n"
" # Perfect 1:1 mapping: query i <-> train i.\n"
" matches = np.zeros((n, 2), dtype=np.int32)\n"
" for i in range(n):\n"
" matches[i, 0] = i\n"
" matches[i, 1] = i\n"
" return matches\n";
return path;
}
// Build N descriptors of dimension dim. Cell (i, j) = i + 0.01*j so the test
// can identify them, though the stub doesn't actually use the content.
cv::Mat makeDescriptors(int n, int dim)
{
cv::Mat d(n, dim, CV_32FC1);
for(int i = 0; i < n; ++i)
for(int j = 0; j < dim; ++j)
d.at<float>(i, j) = float(i) + 0.01f * float(j);
return d;
}
std::vector<cv::KeyPoint> makeKeypoints(int n)
{
std::vector<cv::KeyPoint> kpts;
kpts.reserve(n);
for(int i = 0; i < n; ++i)
{
kpts.emplace_back(float(i * 4), float(i * 4), 8.0f, -1.0f, 0.5f);
}
return kpts;
}
} // namespace
// Full happy path: script loads, init() receives all 5 args, match() returns
// a 1:1 mapping that survives back into cv::DMatch entries.
TEST(PyMatcher, BasicMatching)
{
const std::string scriptPath = writeStubScript(1);
const int dim = 8;
PyMatcher matcher(scriptPath,
/*matchThreshold=*/0.2f,
/*iterations=*/20,
/*cuda=*/false,
/*model=*/"indoor");
EXPECT_EQ(scriptPath, matcher.path());
EXPECT_FLOAT_EQ(0.2f, matcher.matchThreshold());
EXPECT_EQ(20, matcher.iterations());
EXPECT_FALSE(matcher.cuda());
EXPECT_EQ("indoor", matcher.model());
cv::Mat descFrom = makeDescriptors(3, dim);
cv::Mat descTo = makeDescriptors(3, dim);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(3);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(3);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(640, 480));
ASSERT_EQ(3u, matches.size());
for(int i = 0; i < 3; ++i)
{
EXPECT_EQ(i, matches[i].queryIdx);
EXPECT_EQ(i, matches[i].trainIdx);
}
UFile::erase(scriptPath);
}
// Asymmetric descriptor counts: stub returns min(nFrom, nTo) matches. Pin
// that the C++ parser handles non-square Nx2 arrays correctly.
TEST(PyMatcher, AsymmetricCounts)
{
const std::string scriptPath = writeStubScript(2);
const int dim = 4;
PyMatcher matcher(scriptPath, 0.2f, 20, false, "indoor");
cv::Mat descFrom = makeDescriptors(5, dim);
cv::Mat descTo = makeDescriptors(3, dim);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(5);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(3);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(320, 240));
EXPECT_EQ(3u, matches.size()); // limited by the smaller side
UFile::erase(scriptPath);
}
// A non-existent script path is pre-validated by the constructor; match()
// then short-circuits because pModule_ is null.
TEST(PyMatcher, MissingPathReturnsEmpty)
{
const std::string scriptPath = uFormat(
"/tmp/rtabmap_test_pymatcher_does_not_exist_%d.py", getpid());
PyMatcher matcher(scriptPath, 0.2f, 20, false, "indoor");
cv::Mat descFrom = makeDescriptors(2, 8);
cv::Mat descTo = makeDescriptors(2, 8);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(2);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(2);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(64, 64));
EXPECT_TRUE(matches.empty());
}
// match() requires same descriptor dim on both sides; mismatched cols hit
// the "Invalid inputs" guard and return empty.
TEST(PyMatcher, MismatchedDescriptorDimReturnsEmpty)
{
const std::string scriptPath = writeStubScript(3);
PyMatcher matcher(scriptPath, 0.2f, 20, false, "indoor");
cv::Mat descFrom = makeDescriptors(2, 8);
cv::Mat descTo = makeDescriptors(2, 16);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(2);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(2);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(64, 64));
EXPECT_TRUE(matches.empty());
UFile::erase(scriptPath);
}
// match() also rejects non-CV_32F descriptors -- the input-guard in
// PyMatcher::match insists on float descriptors.
TEST(PyMatcher, NonFloatDescriptorsReturnEmpty)
{
const std::string scriptPath = writeStubScript(4);
PyMatcher matcher(scriptPath, 0.2f, 20, false, "indoor");
cv::Mat descFrom = cv::Mat::zeros(2, 8, CV_8UC1);
cv::Mat descTo = cv::Mat::zeros(2, 8, CV_8UC1);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(2);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(2);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(64, 64));
EXPECT_TRUE(matches.empty());
UFile::erase(scriptPath);
}
// Zero-area imageSize is rejected by the input guard.
TEST(PyMatcher, ZeroImageSizeReturnsEmpty)
{
const std::string scriptPath = writeStubScript(5);
PyMatcher matcher(scriptPath, 0.2f, 20, false, "indoor");
cv::Mat descFrom = makeDescriptors(2, 8);
cv::Mat descTo = makeDescriptors(2, 8);
std::vector<cv::KeyPoint> kptsFrom = makeKeypoints(2);
std::vector<cv::KeyPoint> kptsTo = makeKeypoints(2);
std::vector<cv::DMatch> matches = matcher.match(
descFrom, descTo, kptsFrom, kptsTo, cv::Size(0, 0));
EXPECT_TRUE(matches.empty());
UFile::erase(scriptPath);
}
#endif // RTABMAP_PYTHON
+100
View File
@@ -0,0 +1,100 @@
// Tests for PythonInterface and its companion getPythonTraceback() helper.
// Mostly regression coverage for the NULL-safety fixes in getPythonTraceback
// (early-return on no exception, Py_None substitution for missing fetch
// components, NULL-safe DECREFs).
//
// The whole file is a no-op when rtabmap is built without Python -- the
// CMakeLists.txt only registers it under WITH_PYTHON AND Python3_FOUND, and
// a defensive #ifdef matches that.
#include <gtest/gtest.h>
#include <rtabmap/core/Version.h>
#ifdef RTABMAP_PYTHON
#include <rtabmap/core/PythonInterface.h>
#include <pybind11/embed.h>
#include <Python.h>
#include <string>
using namespace rtabmap;
namespace {
// All tests need a live interpreter; instance() lazily initializes it.
class PythonInterfaceTest : public ::testing::Test
{
protected:
void SetUp() override
{
PythonInterface::instance("test_python_interface");
}
};
} // namespace
// instance() must return the same object every call and never re-initialize
// the embedded interpreter. Pinning this so any future refactor can't
// accidentally regress to "construct a new interpreter per call" (which
// pybind11's scoped_interpreter does not support in the same process).
TEST_F(PythonInterfaceTest, InstanceIsSingleton)
{
PythonInterface & a = PythonInterface::instance("test_a");
PythonInterface & b = PythonInterface::instance("test_b");
EXPECT_EQ(&a, &b);
}
// No active Python exception -> the helper returns a clear sentinel instead
// of crashing inside Py_BuildValue("OOO", NULL, NULL, NULL). The sentinel
// makes it obvious in logs that there was nothing useful to report.
TEST_F(PythonInterfaceTest, TracebackEmptyWhenNoException)
{
pybind11::gil_scoped_acquire acquire;
PyErr_Clear();
ASSERT_FALSE(PyErr_Occurred());
EXPECT_EQ("<no python exception set>", getPythonTraceback());
}
// With a real exception set, the formatter returns a non-empty string that
// includes the original message. getPythonTraceback consumes the exception
// (PyErr_Fetch clears it), so PyErr_Occurred is false afterward.
TEST_F(PythonInterfaceTest, TracebackFormattedAfterException)
{
pybind11::gil_scoped_acquire acquire;
PyErr_Clear();
PyErr_SetString(PyExc_RuntimeError, "test error message");
const std::string trace = getPythonTraceback();
EXPECT_FALSE(trace.empty());
EXPECT_NE("<no python exception set>", trace);
EXPECT_NE(std::string::npos, trace.find("test error message"));
EXPECT_FALSE(PyErr_Occurred());
}
// Repeated calls on the no-exception path must stay stable. The original
// crashes (Py_DECREF on NULL outStr, Py_BuildValue on NULL slots) surfaced
// non-deterministically after several invocations -- this loop would have
// reliably tripped them.
TEST_F(PythonInterfaceTest, RepeatedCallsAreStable)
{
pybind11::gil_scoped_acquire acquire;
for(int i = 0; i < 25; ++i)
{
PyErr_Clear();
EXPECT_EQ("<no python exception set>", getPythonTraceback());
}
// Same robustness check with an exception each time.
for(int i = 0; i < 25; ++i)
{
PyErr_SetString(PyExc_ValueError, "boom");
const std::string trace = getPythonTraceback();
EXPECT_FALSE(trace.empty());
EXPECT_NE("<no python exception set>", trace);
}
}
#endif // RTABMAP_PYTHON