Files
rtabmap/corelib/test/test_registrationicp.cpp
matlabbe ee49beaf4f Adding doc and tests (#1492)
* added doc and tests for util2d.h

* updated cmake-ros ci

* Added util3d.h doc and tests

* util3d_transforms.h: Added doc and tests

* util3d_filtering.h: started doc and test

* util3d_filtering.h: more tests and doc

* Added more doc/tests

* finished util3d_filtering doc and tests

* added test for util2d::depthBleedingFiltering

* Added util3d_registration tests

* Added util3d_features.h doc/tests

* added doc/tests for util3d_correspondences.h

* added doc/gtest for util3d_mapping.h (missing hpp functions)

* finished testing util3d_mapping.hpp

* Added util3d_motion_estimation.h tests (2D->3D done)

* finished util3d_motion_estimation.h tests

* minimal util3d_surface.h

* Added Transform and VisualWord tests

* Added doc for CameraModel and StereoCameraModel

* Added more logs in ros ci

* Passing tests on fical

* improved all devcontainer

* added devcontainer kilted, fixed source setup.bash, removed ldconfig in ros-cmake workflow

* cleanup

* source ros

* Added utilite tests

* Added testing to appveyor, github actions cancellable on re-commit on same branch

* appveyor testing without all targets

* appveyor: specifying ALL_BUILD target

* Fixed Util2dTest.NMSImageBoundsRespected test

* Fixing PCL Indices error on old pcl

* Added VWDictionary tests and doc. Fixed LSH not working (fix from https://github.com/flann-lib/flann/pull/472

* fixing some appveyor CI errors, added test to check dictionary serialization against all type

* Added StereoDense, StereoBM and StereoSGBM doc and tests

* Added Stereo tests

* Added CameraModel and StereoCameraModel tests

* Added doc and test for Statistics

* Added doc/tests for Signature

* Added doc/test for SensorEvent, added doc for SensorCaptureInfo

* Added doc to SensorData

* Added SensorData tests

* Added SensorCapture and SensorCaptureThread doc and tests

* fixed sensordata test

* updated SSC test and doc

* Added doc and tests for BayesFilter class

* Enabled testing on mac, updated windows testing like on linux

* added test_link

* fixed unresolved on windows

* fixed ThreadHandle error on macos ci

* Added GPS and GeodeticCoords tests

* Added tests for compression

* Added Odometry tests (base class only)

* Added DBDriver tests

* Added coverage report

* uniformized test names

* fixing concurancy and coverage ci

* dont built tools, examples and app for coverage build

* fixed report tool rebuilt without qt compilation error

* updated coverage option

* updated coverage config

* added doc CI job

* fixing windows and mac ci errors

* Added DBDriverSqlite3 tests

* Added IMU tests

* Added Graph tests

* fixing flaky macos test

* Added IMUThread and IMUFilter tests

* Added Landmarks tests

* Added LASWriter tests

* fixing seed flaky test

* fixing flaky macos timing tests

* Added LocalGrid tests

* Added LocalGridMaker tests

* fixing ci errors

* Added GlobalMap tests

* Added doc for EnvSensor

* Added Features2D tests

* Added Registration tests

* Added RegistrationVis tests

* Added doc for Rtabmap and Memory classes

* Added Memory and Rtabmap tests

* making some tests less flaky

* lcov 1.14 support

* updated compatible tool arguments

* Added integration tests (RGB-D, Stereo, Lidar2d, Lidar3d)

* More octomap checks

* Refactored how/when python interpretor is created to simplify library usage

* Added python tests

* fixed some flaky tests

* suppressed some third party related warnings

* fixed ceres tests

* more flaky fixes

* Fixing tests without libpointmatcher

* Added RANSAC rejection filter to PCL ICP

* fixing multi platform flakiness

* Added test to detect regression

* Fixing windows pcl link error

* fixed some macos flakiness

* bigger 2D2D registration error on opencv 4.6.0

* flakiness

* fixing flaky tests on windows and mac

* flaky thread test on slow mac VM

* windows slow test

* fixing more ci erros

* fxing temp dir on windows

* Added Optimizer tests and discovered some bugs (fixed)

* fixing flaky tests in mac and windows

* Added Optimizer doc

* Added GTSAM BA, updated Ceres to use g2o ba parameters. Renamed g2o's ba related parameters to Optimizer group and used by both gtsam and ceres.

* fixing build without gtsam

* fixing home dir

* fixing python ci isssues

* Added multicam ba tests

* Added Ceres multicam BA support

* Aligned BundleAdjustment parameters with Optimizer/Strategy to avoid confusion in the code

* Added BA integration test

* Added robust graph optimization integration test

* Added loop3it test

* Added stereo20Hz test

* Added smartfactor gtsam

* Fixed bugged check and warn if python didn't return any descriptors

* Fixing gtsam version build issues

* fixing tilt on windows ci

* loosing ceres integration test for ci

* mac ci flakiness

* updating missing param in gui

* updating test bound for mac

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

* testing more stuff

* improving features2d tests

* ci flakiness

* fixing flaky ci

* ci fixes

* flaky fixes

* Added RegistrationIcp tests

* Added icp integration test with real-worl corridor like env

* intermediate nodes

* fixing enum

* Updated test to catch #1714

* Fixed 2d corridor failing on pcl

* flaky pnp test

* flaky brisk test

* Set rtabmap_integration test as long

* updating loop closure test

* flaky ci tests

* TEsting roundtrip g2o/toro save/load

* loosing test bound

* fixed cuda capable checks

* flaky tests

* Debugging test hanging

* more debugging stuff

* updating limit

* windows: disabled cuda on ci to avoid incompatible driver issue. Fixing a bad test mem allocation

* trying fixing cuda hanging issue

* fixing ci flakyness

* flaky tests

* Updated BOW flaky tests by checking min precision/recall instead of recall@100precision. Fixed signature test

* CameraModel::load() test initRectificationMap param

* test dbdriver load dictionary idsOnly

* Memory: test keepLinkedInDb param

* added dummyDictionary tests

* test intermediate nodes count

* Added MarkerDetector tests

* reverted breaking change of UMutex and USemaphore

* Features2d: fixed compiltion warnings with clang about override

* clang warnings

* fixing test build with pcl 1.8

* g2o and gtsam build errors on android

* opencv5 test fixes

* disabled testing for ios and android builds

* normalized endline characters for easier diff

* added LF CRLF rule

* bump 0.23.10. fixing doc version

* Publish rtabmap website doc from ci

* fixing MSCVC build error

* macos icp flaky test

* fixing ceres macos test bound

* ficing more flaky tests

* fixing opencv5 related test errors. Also fixed an actual bug in ENU_WGS84ToGeocentric_WGS84()

* added comment about mrpt change

* removed rosdoc2 (will add it for rtabmap_ros later)

* fixing website style

* updated download links

* locally deployable website with api

* sweep doxygen issues

* improved/revised doxygen main pages

* removed examples empty page

* Updated doxygen style

* more concise doxygen groups

* added api link on main readme

* fixing utilite test error

* fixing CommonFilteringGroundNormalsUp test

* updated precisionRecall test bounds for Freak and brief descriptors

* fixing scale check in ba tests

* disabled tests on windows cuda build (missing dlls amd runner cannot test cuda anyway)

* ceres: missing suitesparse dep in windows ci

* adjusting recall thr for fast/freak

* ficing more flaky tests

* fixing flaky tests

* disabled coverage in ros ci

* Enable integration tests for ros ci jobs

* loosing up some threshold for failing tests

* trigger cache

* fixing test data in ros ci. Updated flaky test for mac

* slaking some test limit

* Fixed rtabmap-detectMoreLoopClosures inverted output value

* loosing up sift recall on mac

* optimizer re-ordered distribution for reproducible results (mac g2o)

* macos dump test crash log

* combining all tests to save time on shared library reload. Also fixed Logs with missing arguments.

* Added ENABLE_FORMAT_ERRORS cmake option

* do test only one time

* fixed all format warnings

* format security android build errors

* less verbose tests

* updated ImuUThread test

* fixed a log

* Fixed libpointmatcher 2d normals eigen issue

* Fixing libpointmatcher conversion issues

* fixing libpointmatcher test on windows ci

* cleanup comments, relax some test thr

* disabled sequoia-intel ci build (too flaky, would need extensive testing directly on that machine)
2026-08-06 13:32:20 -07:00

1474 lines
59 KiB
C++

// Tests for RegistrationIcp -- geometric (laser scan) registration via ICP.
//
// We feed the registrar a deterministic synthetic cloud of 50 tight Gaussian
// blobs scattered in [-1,1]^3, apply a known transform to one copy, and verify
// the recovered transform matches truth. The clustered structure gives each
// blob a distinctive local neighbourhood ICP's nearest-neighbour search can
// lock onto -- crucial for CCCoreLib, whose KD-tree-only correspondence
// pass loses lock on uniform random clouds past ~7 deg of misalignment.
//
// Recovery / filter / constraint tests are parameterized across every ICP
// backend built into the binary (PCL always; libpointmatcher and CCCoreLib
// when their build flags are set). Plumbing tests stay non-parameterized.
//
// The LaserScan <-> libpointmatcher DataPoints round-trip tests at the bottom
// of this file need Eigen's bounds checks, which NDEBUG compiles out in a
// Release build; overriding eigen_assert to throw keeps them live here. This
// has to come before any header that pulls in Eigen.
#include <stdexcept>
#include <string>
#define eigen_assert(x) \
do { if(!(x)) throw std::runtime_error(std::string("eigen_assert failed: ") + #x); } while(0)
#include <gtest/gtest.h>
#include <rtabmap/core/RegistrationIcp.h>
#include <rtabmap/core/Parameters.h>
#include <rtabmap/core/LaserScan.h>
#include <rtabmap/core/SensorData.h>
#include <rtabmap/core/Signature.h>
#include <rtabmap/core/Transform.h>
#include <rtabmap/core/Version.h>
#include <rtabmap/core/util3d_transforms.h>
#include <rtabmap/utilite/UConversion.h>
#include <opencv2/core.hpp>
#include <cmath>
#include <vector>
#ifdef RTABMAP_POINTMATCHER
// Private header, included directly so the LaserScan <-> DataPoints
// conversions can be tested without going through a full ICP run. It expects
// pcl point types and util3d to be in scope.
#include <pcl/point_cloud.h>
#include <pcl/point_types.h>
#include <rtabmap/core/util3d.h>
#include "icp/libpointmatcher.h"
#endif
using namespace rtabmap;
namespace {
// Iterate every IcpStrategy enum value the library exposes and keep the ones
// this build actually links in. Lets tests run once per available backend
// without per-CI #ifdef gymnastics in the test body.
std::vector<RegistrationIcp::IcpStrategy> availableIcpStrategies()
{
std::vector<RegistrationIcp::IcpStrategy> s;
for(int i = RegistrationIcp::kIcpPCL; i < RegistrationIcp::kIcpEnd; ++i)
{
const auto strategy = static_cast<RegistrationIcp::IcpStrategy>(i);
if(RegistrationIcp::available(strategy)) s.push_back(strategy);
}
return s;
}
// 50 tight Gaussian blobs scattered through [-1,1]^3, 100 points each (5000
// total). Each blob is a distinctive local feature ICP's nearest-neighbour
// search can lock onto, so correspondences stay correct across larger initial
// misalignments than a uniform cloud allows. Tested with all three backends
// (PCL / libpointmatcher / CCCoreLib)
cv::Mat makeRandomCloud(int numBlobs = 50, int pointsPerBlob = 100, float blobSigma = 0.04f, uint64_t seed = 0xC0FFEE)
{
cv::RNG rng(seed);
cv::Mat data(1, numBlobs * pointsPerBlob, CV_32FC3);
int idx = 0;
for(int c = 0; c < numBlobs; ++c)
{
const cv::Vec3f centre(
rng.uniform(-0.9f, 0.9f),
rng.uniform(-0.9f, 0.9f),
rng.uniform(-0.9f, 0.9f));
for(int i = 0; i < pointsPerBlob; ++i, ++idx)
{
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(
centre[0] + static_cast<float>(rng.gaussian(blobSigma)),
centre[1] + static_cast<float>(rng.gaussian(blobSigma)),
centre[2] + static_cast<float>(rng.gaussian(blobSigma)));
}
}
return data;
}
LaserScan makeScan(const cv::Mat & scanData)
{
return LaserScan(scanData, /*maxPoints=*/0, /*maxRange=*/0.0f, LaserScan::kXYZ);
}
// 2D laser-scan version of @ref makeRandomCloud (kXY format). Uses smaller
// tighter blobs than the 3D variant -- losing the z dimension halves the
// effective spatial separation between cluster centroids, so we counter with
// more, smaller clusters so libpointmatcher and CCCoreLib still find
// distinctive correspondences from an identity guess.
LaserScan makeRandom2DScan(int numBlobs = 100, int pointsPerBlob = 50, float blobSigma = 0.01f, uint64_t seed = 0xC0FFEE)
{
cv::RNG rng(seed);
cv::Mat data(1, numBlobs * pointsPerBlob, CV_32FC2);
int idx = 0;
for(int c = 0; c < numBlobs; ++c)
{
const cv::Vec2f centre(
rng.uniform(-0.9f, 0.9f),
rng.uniform(-0.9f, 0.9f));
for(int i = 0; i < pointsPerBlob; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
centre[0] + static_cast<float>(rng.gaussian(blobSigma)),
centre[1] + static_cast<float>(rng.gaussian(blobSigma)));
}
}
return LaserScan(data, /*maxPoints=*/0, /*maxRange=*/0.0f, LaserScan::kXY);
}
// 3D corridor: two side walls at y=+/-halfWidth, plus floor (z=0) and
// ceiling (z=height). All surfaces extend along the x axis from
// -length/2..+length/2. Walls carry a small y-jitter so the per-point
// normals aren't perfectly colinear (otherwise the normal-PCA's smallest
// eigenvalue rounds to 0 and the low-complexity strategy can't pick a
// projection axis). The corridor is intentionally degenerate along x:
// every surface maps onto itself under a pure-x translation, so ICP can't
// recover x without an external prior.
//
// With all four surfaces equally dense, structural complexity lands around
// ~0.015 -- still under Icp/PointToPlaneMinComplexity=0.02, so the
// low-complexity strategy actively fires.
cv::Mat makeCorridor3D(
float length = 6.0f,
float halfWidth = 0.5f,
float height = 2.0f,
int wallPoints = 500,
int floorPoints = 500,
int ceilingPoints = 500,
float surfaceJitter = 0.005f,
uint64_t seed = 0xC0FFEE,
float floorZ = -0.3f)
{
cv::RNG rng(seed);
const int total = 2 * wallPoints + floorPoints + ceilingPoints;
cv::Mat data(1, total, CV_32FC3);
int idx = 0;
const float halfL = 0.5f * length;
const float ceilingZ = floorZ + height;
auto wall = [&](float yBase) {
for(int i = 0; i < wallPoints; ++i, ++idx)
{
const float x = rng.uniform(-halfL, halfL);
const float y = yBase + static_cast<float>(rng.gaussian(surfaceJitter));
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(x, y, rng.uniform(floorZ, ceilingZ));
}
};
wall(-halfWidth);
wall( halfWidth);
auto horizontalSurface = [&](float zBase) {
for(int i = 0; i < floorPoints; ++i, ++idx)
{
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(
rng.uniform(-halfL, halfL),
rng.uniform(-halfWidth, halfWidth),
zBase + static_cast<float>(rng.gaussian(surfaceJitter)));
}
};
horizontalSurface(floorZ); // floor below origin so viewpoint is inside corridor
horizontalSurface(ceilingZ); // ceiling above origin
return data;
}
// 3D room corner: three mutually perpendicular planes (floor + 2 walls),
// centered around the origin so the viewpoint at origin sits inside the
// corner volume (floor below, walls behind/left).
// Floor: z=-half, normal +z, x/y in [-half, half].
// Wall x: x=-half, normal +x, y/z in [-half, half].
// Wall y: y=-half, normal +y, x/z in [-half, half].
// Each surface carries Gaussian jitter normal to the plane so per-point
// normals are well defined. With Icp/PointToPlaneComplexityCentered=false
// (default), the uncentered second-moment metric correctly identifies all
// 3 DoF as constrained -- complexity lands well above the 0.02 threshold
// and the low-complexity fallback does NOT fire.
cv::Mat makeRoomCorner3D(
float length = 2.0f,
int pointsPerSurface = 500,
float surfaceJitter = 0.005f,
uint64_t seed = 0xC0FFEE)
{
cv::RNG rng(seed);
cv::Mat data(1, 3 * pointsPerSurface, CV_32FC3);
int idx = 0;
const float half = 0.5f * length;
// Floor: z=-half, jitter on z.
for(int i = 0; i < pointsPerSurface; ++i, ++idx)
{
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(
rng.uniform(-half, half),
rng.uniform(-half, half),
-half + static_cast<float>(rng.gaussian(surfaceJitter)));
}
// Wall at x=-half: jitter on x.
for(int i = 0; i < pointsPerSurface; ++i, ++idx)
{
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(
-half + static_cast<float>(rng.gaussian(surfaceJitter)),
rng.uniform(-half, half),
rng.uniform(-half, half));
}
// Wall at y=-half: jitter on y.
for(int i = 0; i < pointsPerSurface; ++i, ++idx)
{
data.at<cv::Vec3f>(0, idx) = cv::Vec3f(
rng.uniform(-half, half),
-half + static_cast<float>(rng.gaussian(surfaceJitter)),
rng.uniform(-half, half));
}
return data;
}
// 2D corridor: two parallel lines at y=+/-halfWidth extending along x. Same
// degeneracy as the 3D version -- pure-x translation maps each line onto
// itself, so x is unobservable without a guess.
//
// Note: pointsPerLine of perfectly straight points along y=const give all
// normals (0, +/-1) exactly -- PCA on these has zero variance in any
// direction except y, so structural complexity rounds to zero and the
// low-complexity strategy can't decide a projection axis. A 5 mm y-jitter
// breaks the colinearity enough for the normal-PCA to compute meaningful
// eigenvectors while keeping the complexity at ~0.003 (well below the 0.02
// threshold, so the low-complexity strategy still fires).
LaserScan makeCorridor2D(
float length = 6.0f,
float halfWidth = 0.5f,
float yJitter = 0.005f,
int pointsPerLine = 500,
uint64_t seed = 0xC0FFEE)
{
cv::RNG rng(seed);
cv::Mat data(1, pointsPerLine * 2, CV_32FC2);
int idx = 0;
const float halfL = 0.5f * length;
for(int s = 0; s < 2; ++s)
{
const float yBase = (s == 0 ? -halfWidth : halfWidth);
for(int i = 0; i < pointsPerLine; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
rng.uniform(-halfL, halfL),
yBase + rng.uniform(-yJitter, yJitter));
}
}
return LaserScan(data, /*maxPoints=*/0, /*maxRange=*/0.0f, LaserScan::kXY);
}
// 2D room corner: two perpendicular line segments centered around the
// origin so the viewpoint at origin sits inside the L-shape volume.
// Floor: y=-half, normal +y, x in [-half, half].
// Wall: x=-half, normal +x, y in [-half, half].
// With Icp/PointToPlaneComplexityCentered=false (default), the uncentered
// second-moment metric correctly identifies both DoF as constrained --
// complexity lands well above the 0.02 threshold.
LaserScan makeRoomCorner2D(
float length = 2.0f,
float jitter = 0.005f,
int pointsPerLine = 500,
uint64_t seed = 0xC0FFEE)
{
cv::RNG rng(seed);
cv::Mat data(1, 2 * pointsPerLine, CV_32FC2);
int idx = 0;
const float half = 0.5f * length;
// Floor: y=-half, jitter on y.
for(int i = 0; i < pointsPerLine; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
rng.uniform(-half, half),
-half + rng.uniform(-jitter, jitter));
}
// Wall: x=-half, jitter on x.
for(int i = 0; i < pointsPerLine; ++i, ++idx)
{
data.at<cv::Vec2f>(0, idx) = cv::Vec2f(
-half + rng.uniform(-jitter, jitter),
rng.uniform(-half, half));
}
return LaserScan(data, /*maxPoints=*/0, /*maxRange=*/0.0f, LaserScan::kXY);
}
Signature makeSignature(int id, const LaserScan & scan)
{
SensorData data;
data.setId(id);
data.setLaserScan(scan);
return Signature(data);
}
ParametersMap baseIcpParams(RegistrationIcp::IcpStrategy strategy)
{
ParametersMap p;
p.insert(ParametersPair(Parameters::kIcpStrategy(), uNumber2Str(static_cast<int>(strategy))));
// Epsilon=1e-3 (1 mm-equivalent) so the differential-convergence stop
// condition can actually fire on our pristine synthetic clouds.
// 1e-6 squared becomes 1e-12 inside libpointmatcher's
// DifferentialTransformationChecker -- effectively never trips, every
// run burns the full iteration budget.
p.insert(ParametersPair(Parameters::kIcpEpsilon(), "1e-3"));
p.insert(ParametersPair(Parameters::kIcpIterations(), "50"));
// Leave Icp/OutlierRatio at its default (0.85). Setting it to 0.0 (to
// "disable" the PCL RANSAC rejector) also disables libpointmatcher's
// TrimmedDistOutlierFilter, which rejects ratios <= 0 with an exception.
// 0.5 m max correspondence distance keeps a comfortable margin over the
// largest motion we test (10 cm + 10 deg ~= 0.2 m max point displacement
// over the 2 m cloud diameter).
p.insert(ParametersPair(Parameters::kIcpMaxCorrespondenceDistance(), "0.5"));
// CCCoreLib has its own RMS-based rejection threshold (Icp/CCMaxFinalRMS,
// default 0.2). Bump it so synthetic clouds with 5-10 cm motion don't
// trip the rejector before the test's own MaxTranslation/MaxRotation
// gates get a chance.
p.insert(ParametersPair(Parameters::kIcpCCMaxFinalRMS(), "5.0"));
// Disable point-to-plane by default: our random blob clouds aren't
// surfaces, so normal estimation triggers the low-complexity fallback
// path that can zero out translation components. PointToPlane stays
// available for the test that opts in explicitly.
p.insert(ParametersPair(Parameters::kIcpPointToPlane(), "false"));
// Force RegistrationIcp to its standalone path (no child).
p.insert(ParametersPair(Parameters::kRegRepeatOnce(), "false"));
p.insert(ParametersPair(Parameters::kRegForce3DoF(), "false"));
return p;
}
// Default strategy for non-parameterized plumbing tests: prefer
// libpointmatcher if built, then CCCoreLib, then PCL.
RegistrationIcp::IcpStrategy defaultIcpStrategy()
{
if(RegistrationIcp::available(RegistrationIcp::kIcpPointMatcher)) return RegistrationIcp::kIcpPointMatcher;
if(RegistrationIcp::available(RegistrationIcp::kIcpCCCoreLib)) return RegistrationIcp::kIcpCCCoreLib;
return RegistrationIcp::kIcpPCL;
}
Transform runIcp(
RegistrationIcp::IcpStrategy strategy,
const LaserScan & fromScan,
const LaserScan & toScan,
const ParametersMap & extra = ParametersMap(),
const Transform & guess = Transform::getIdentity(),
RegistrationInfo * infoOut = nullptr)
{
ParametersMap params = baseIcpParams(strategy);
for(const auto & kv : extra) params[kv.first] = kv.second;
RegistrationIcp reg(params);
Signature from = makeSignature(1, fromScan);
Signature to = makeSignature(2, toScan);
RegistrationInfo info;
const Transform t = reg.computeTransformation(from, to, guess, &info);
if(infoOut) *infoOut = info;
return t;
}
// Recovery tolerance, shared by every backend.
//
// It must stay comfortably above Icp/Epsilon (1e-3, see baseIcpParams): that is
// the differential-convergence stop condition, so ICP deliberately stops once an
// iteration moves less than ~1 mm and a residual of a few mm is expected by
// construction. Exactly where each backend stops shifts with the library version
// and the platform's floating-point behaviour -- measured on the same clustered
// cloud, PCL lands around 3.0 mm and libpointmatcher around 4.6 mm on Linux,
// while libpointmatcher reaches 5.6 mm on macOS. A 5e-3 bound left no margin over
// that spread and failed on macOS only; 1e-2 (16% of the 62 mm motion under test)
// still catches a genuinely broken registration.
//
// Tighten this only together with Icp/Epsilon, never on its own.
constexpr float kRecoveryTol = 1e-2f;
// Parameterized fixture: each test instance runs once per available backend.
class RegistrationIcpStrategyTest : public ::testing::TestWithParam<RegistrationIcp::IcpStrategy>
{
protected:
RegistrationIcp::IcpStrategy strategy() const { return GetParam(); }
float tol() const { return kRecoveryTol; }
};
} // namespace
// =====================================================================
// IcpStrategy-agnostic plumbing tests (single backend, default strategy)
// =====================================================================
TEST(RegistrationIcpTest, FlagsAdvertiseScanRequiredAndGuessSupport)
{
RegistrationIcp reg(baseIcpParams(defaultIcpStrategy()));
EXPECT_TRUE(reg.isScanRequired());
EXPECT_FALSE(reg.isImageRequired());
EXPECT_TRUE(reg.canUseGuess());
}
TEST(RegistrationIcpTest, ParseParametersOverridesDefaults)
{
ParametersMap p = baseIcpParams(defaultIcpStrategy());
p[Parameters::kRegForce3DoF()] = "true";
p[Parameters::kIcpMaxTranslation()] = "0.5";
p[Parameters::kIcpMaxRotation()] = "1.5";
RegistrationIcp reg(p);
EXPECT_TRUE(reg.force3DoF());
// Re-parse with new values.
ParametersMap update;
update[Parameters::kRegForce3DoF()] = "false";
reg.parseParameters(update);
EXPECT_FALSE(reg.force3DoF());
}
// =====================================================================
// Corridor degeneracy: low-complexity detection + recovery strategy
//
// In a corridor (long, parallel walls), translation along the corridor axis
// is geometrically unobservable -- any pure-x translation maps the scan
// onto itself. rtabmap detects this via @ref Icp/PointToPlaneMinComplexity
// and falls back to a strategy controlled by @ref
// Icp/PointToPlaneLowComplexityStrategy. These tests exercise Strategy 3
// (keep PointToPlane + project onto constrained axes), which yields cleaner
// y/yaw recovery than Strategy 1 (the default, legacy: recompute with
// PointToPoint then project) on these synthetic clouds.
//
// These tests use the default ICP backend only -- the low-complexity logic
// lives in RegistrationIcp itself, so the backend underneath isn't what's
// under test.
// =====================================================================
namespace {
// Helper for the corridor tests: PointToPlane=true with normals auto-computed
// from a k-neighborhood; low-complexity strategy 3 (keep PointToPlane + project
// along the degenerate axis); 5 cm voxel downsampling on both scans before ICP.
ParametersMap corridorIcpParams(RegistrationIcp::IcpStrategy strategy)
{
ParametersMap p = baseIcpParams(strategy);
p[Parameters::kRegForce3DoF()] = "true";
p[Parameters::kIcpPointToPlane()] = "true";
// PCL's normal estimator wants either K or radius, not both. Use a
// radius search (0.3 m -- about half the corridor width) so wall
// normals stay locally consistent even when the cloud is sparse.
p[Parameters::kIcpPointToPlaneK()] = "0";
p[Parameters::kIcpPointToPlaneRadius()] = "0.3";
// Default MinComplexity = 0.02 already detects a corridor; keep it
// explicit so the test documents what threshold is being exercised.
p[Parameters::kIcpPointToPlaneMinComplexity()] = "0.02";
// Strategy 3 (keep PointToPlane + project): PointToPlane's normal-dot
// residual gives clean y/yaw recovery on the synthetic corridor walls.
// The default Strategy 1 (recompute with PointToPoint + project) is
// more robust on real-world F2M drift (where libpointmatcher iteration
// can wander on degenerate scans -- see the PR2_Scan2D_Corridor
// integration test), but on these clean clouds PointToPoint loses
// signal on the y axis and yaw collapses toward zero (~0.5 deg out of
// 3 deg). So this helper opts back into Strategy 3.
p[Parameters::kIcpPointToPlaneLowComplexityStrategy()] = "3";
// 5 cm voxel: downsamples the dense synthetic walls while keeping enough
// points to estimate normals + run ICP -- closer to a real-world setup.
p[Parameters::kIcpVoxelSize()] = "0.05";
p[Parameters::kIcpMaxTranslation()] = "0.0";
return p;
}
// Corridor tests are inherently about the PointToPlane + low-complexity
// strategy path; CCCoreLib doesn't expose PointToPlane in rtabmap's wrapper
// so it's excluded from this fixture.
// PointToPlane-capable backends -- everything except CCCoreLib among the
// available strategies in this build.
std::vector<RegistrationIcp::IcpStrategy> pointToPlaneCapableStrategies()
{
std::vector<RegistrationIcp::IcpStrategy> s;
for(auto strategy : availableIcpStrategies())
{
if(strategy != RegistrationIcp::kIcpCCCoreLib) s.push_back(strategy);
}
return s;
}
// Corridor tests model an "infinite" corridor: the truth motion is the
// actual robot motion in the world, but x is unobservable -- the corridor
// looks statistically identical 1 m forward. We strip the x component
// from truth when transforming the `to` scan so ICP only ever sees the
// observable subset of the motion.
Transform observableMotion(const Transform & truth)
{
float roll, pitch, yaw;
truth.getEulerAngles(roll, pitch, yaw);
return Transform(0.0f, truth.y(), truth.z(), roll, pitch, yaw);
}
// Parameterized fixture for the corridor degeneracy tests.
class CorridorTest : public ::testing::TestWithParam<RegistrationIcp::IcpStrategy>
{
protected:
RegistrationIcp::IcpStrategy strategy() const { return GetParam(); }
};
} // namespace
TEST_P(CorridorTest, NoGuess_FailsToRecoverXButGetsYAndYaw)
{
// Corridor along x. Truth: 1 m forward, 5 cm sideways, 3 deg yaw.
// Infinite-corridor model: `to` is an independent sample of the same
// corridor shape, transformed by only the observable subset of truth
// (x stripped). With identity guess, ICP can't observe the x motion;
// strategy=1 (PCL / libpointmatcher) pins x at the guess.
const LaserScan from = makeScan(makeCorridor3D());
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, corridorIcpParams(strategy()),
Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(info.icpStructuralComplexity, 0.02f)
<< "expected corridor to land below low-complexity threshold; got "
<< info.icpStructuralComplexity;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(x), 0.01f)
<< "expected x to stay near guess (=0), got " << x;
EXPECT_GT(std::abs(x - truth.x()), 0.5f)
<< "expected x to be way off truth; got " << x;
EXPECT_NEAR(0.05f, y, 5e-2f);
EXPECT_NEAR(yaw, yawOut, 5e-2f);
}
TEST_P(CorridorTest, WithXGuess_RecoversFullTransform)
{
// Same corridor + truth, but seed ICP with a guess that supplies the
// x translation (and nothing else). Both backends recover the full
// transform when x is anchored by the guess.
const LaserScan from = makeScan(makeCorridor3D());
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
const Transform xGuess(1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, corridorIcpParams(strategy()),
xGuess, &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), 2e-2f)
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), 1e-2f);
}
// Helper for the drone tests: same as corridorIcpParams() but full 6DoF
// (no Reg/Force3DoF).
ParametersMap corridorIcpParams6DoF(RegistrationIcp::IcpStrategy strategy)
{
ParametersMap p = corridorIcpParams(strategy);
p[Parameters::kRegForce3DoF()] = "false";
return p;
}
TEST_P(CorridorTest, Drone_NoGuess_FailsToRecoverXOnly)
{
// Simulate a drone moving in an "infinite" corridor: the truth motion
// is 6DoF, but the corridor looks statistically identical 1 m forward
// so the robot only sees the y/z/roll/pitch/yaw components. `from` and
// `to` are independent samples of the same corridor (different seeds),
// and `to` is transformed by only the observable subset of truth. ICP
// cannot recover x from this input.
const LaserScan from = makeScan(makeCorridor3D());
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.05f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, corridorIcpParams6DoF(strategy()),
Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(info.icpStructuralComplexity, 0.02f)
<< "expected corridor to land below low-complexity threshold; got "
<< info.icpStructuralComplexity;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(x), 0.01f)
<< "expected x to stay near guess (=0), got " << x;
EXPECT_GT(std::abs(x - truth.x()), 0.5f)
<< "expected x to be way off truth; got " << x;
// Only the *perpendicular* DoFs (z, pitch) lock reliably without
// point-to-point correspondences -- they're constrained by direct
// floor/ceiling-normal residuals. y, roll, yaw are all in-plane
// motions for the dense floor/ceiling, where PointToPlane gives zero
// residual; without an x-anchor they drift. The with-guess test
// below shows the same setup recovers fully once x is provided.
EXPECT_NEAR(0.05f, z, 1e-2f);
EXPECT_NEAR(1.5f*deg, pitch, 5e-3f);
// y, roll, yaw: only require they don't blow up.
EXPECT_LT(std::abs(y), 0.1f);
EXPECT_LT(std::abs(roll), 5.0f*deg);
EXPECT_LT(std::abs(yawOut), 5.0f*deg);
}
TEST_P(CorridorTest, Drone_WithXGuess_RecoversFullTransform)
{
// Same infinite-corridor setup, guess provides only the 1 m
// x-translation. The guess equals truth.x, so all three backends
// converge near truth on the remaining 5 DoFs (CC looser since it
// can't run PointToPlane).
const LaserScan from = makeScan(makeCorridor3D());
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.05f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
const Transform xGuess(1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, corridorIcpParams6DoF(strategy()),
xGuess, &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), 2e-2f)
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), 1e-2f);
}
// Force4DoF variants of the drone tests. libpointmatcher only -- it's the
// only backend that exposes a 4DoF PointToPlane minimiser via its force4DOF
// parameter. PCL has no Force4DoF support (RegistrationIcp warns + falls
// back), and CCCoreLib's PointToPoint path doesn't run on PointToPlane.
//
// Models a moving drone with a perfect IMU: the drone really is rolled
// and pitched in the world (full 6DoF truth applied to the `to` scan),
// and the IMU feeds the known world roll/pitch into the guess. Force4DoF
// preserves the guess's roll/pitch (no delta updates to those axes) and
// refines only the (x, y, z, yaw) residual on top, so ICP recovers the
// full 6DoF truth.
TEST(RegistrationIcpTest, Corridor3D_Drone4DoF_ImuOnlyGuess_FailsToRecoverXOnly)
{
if(!RegistrationIcp::available(RegistrationIcp::kIcpPointMatcher))
{
GTEST_SKIP() << "Force4DoF requires libpointmatcher";
}
const LaserScan from = makeScan(makeCorridor3D());
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.05f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
ParametersMap params = corridorIcpParams6DoF(RegistrationIcp::kIcpPointMatcher);
params[Parameters::kIcpForce4DoF()] = "true";
// IMU-only guess: world roll/pitch known, no odometry x.
const Transform imuGuess(0.0f, 0.0f, 0.0f, 2.0f*deg, 1.5f*deg, 0.0f);
RegistrationInfo info;
const Transform t = runIcp(RegistrationIcp::kIcpPointMatcher, from, to, params,
imuGuess, &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(info.icpStructuralComplexity, 0.02f)
<< "expected corridor to land below low-complexity threshold; got "
<< info.icpStructuralComplexity;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(x), 0.01f)
<< "expected x to stay near guess (=0), got " << x;
EXPECT_GT(std::abs(x - truth.x()), 0.5f)
<< "expected x to be way off truth; got " << x;
// Force4DoF preserves the guess's roll/pitch (matching the IMU-known
// world tilt).
EXPECT_NEAR(2.0f*deg, roll, 5e-3f);
EXPECT_NEAR(1.5f*deg, pitch, 5e-3f);
EXPECT_NEAR(0.05f, y, 1e-2f);
EXPECT_NEAR(0.05f, z, 1e-2f);
EXPECT_NEAR(3.0f*deg, yawOut, 5e-3f);
}
TEST(RegistrationIcpTest, Corridor3D_Drone4DoF_ImuPlusXGuess_RecoversFullTransform)
{
if(!RegistrationIcp::available(RegistrationIcp::kIcpPointMatcher))
{
GTEST_SKIP() << "Force4DoF requires libpointmatcher";
}
const LaserScan from = makeScan(makeCorridor3D());
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.05f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
const LaserScan to = util3d::transformLaserScan(
makeScan(makeCorridor3D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*height=*/2.0f, /*wallPoints=*/500, /*floorPoints=*/500,
/*ceilingPoints=*/500, /*surfaceJitter=*/0.005f,
/*seed=*/0xBA5EBA11)),
observableMotion(truth).inverse());
ParametersMap params = corridorIcpParams6DoF(RegistrationIcp::kIcpPointMatcher);
params[Parameters::kIcpForce4DoF()] = "true";
// IMU + x guess: world roll/pitch from IMU, x from odometry.
const Transform guess(1.0f, 0.0f, 0.0f, 2.0f*deg, 1.5f*deg, 0.0f);
RegistrationInfo info;
const Transform t = runIcp(RegistrationIcp::kIcpPointMatcher, from, to, params,
guess, &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_NEAR(1.0f, x, 5e-3f);
EXPECT_NEAR(2.0f*deg, roll, 5e-3f);
EXPECT_NEAR(1.5f*deg, pitch, 5e-3f);
EXPECT_NEAR(0.05f, y, 1e-2f);
EXPECT_NEAR(0.05f, z, 1e-2f);
EXPECT_NEAR(3.0f*deg, yawOut, 5e-3f);
}
// 2D corridor tests: PCL has no 2D PointToPlane estimator
// (TransformationEstimation2D is point-to-point only), so the
// low-complexity machinery never fires on PCL+2D and the iteration
// drifts unchecked. Skip when libpointmatcher isn't available.
TEST(RegistrationIcpTest, Corridor2D_NoGuess_FailsToRecoverXButGetsYAndYaw)
{
if(defaultIcpStrategy() != RegistrationIcp::kIcpPointMatcher)
{
GTEST_SKIP() << "2D PointToPlane requires libpointmatcher";
}
// 2D analogue: two parallel lines along x. Same degeneracy + same
// infinite-corridor model (independent `to` sample, x stripped from
// the truth motion).
const LaserScan from = makeCorridor2D();
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(
makeCorridor2D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*yJitter=*/0.005f, /*pointsPerLine=*/500,
/*seed=*/0xBA5EBA11),
observableMotion(truth).inverse());
RegistrationInfo info;
const Transform t = runIcp(defaultIcpStrategy(), from, to,
corridorIcpParams(defaultIcpStrategy()),
Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(info.icpStructuralComplexity, 0.02f)
<< "expected 2D corridor to land below low-complexity threshold; got "
<< info.icpStructuralComplexity;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(x), 0.1f)
<< "expected x to stay near guess (=0), got " << x;
EXPECT_NEAR(0.05f, y, 1e-2f);
EXPECT_NEAR(yaw, yawOut, 5e-3f);
}
TEST(RegistrationIcpTest, Corridor2D_WithXGuess_RecoversFullTransform)
{
if(defaultIcpStrategy() != RegistrationIcp::kIcpPointMatcher)
{
GTEST_SKIP() << "2D PointToPlane requires libpointmatcher";
}
const LaserScan from = makeCorridor2D();
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(1.0f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(
makeCorridor2D(/*length=*/6.0f, /*halfWidth=*/0.5f,
/*yJitter=*/0.005f, /*pointsPerLine=*/500,
/*seed=*/0xBA5EBA11),
observableMotion(truth).inverse());
const Transform xGuess(1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
RegistrationInfo info;
const Transform t = runIcp(defaultIcpStrategy(), from, to,
corridorIcpParams(defaultIcpStrategy()), xGuess, &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), 2e-2f)
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), 5e-3f);
}
INSTANTIATE_TEST_SUITE_P(
Corridor3D,
CorridorTest,
::testing::ValuesIn(pointToPlaneCapableStrategies()),
[](const ::testing::TestParamInfo<RegistrationIcp::IcpStrategy> & info)
{
return std::string(RegistrationIcp::strategyName(info.param));
});
// =====================================================================
// PointToPlane with perpendicular surfaces: scene fully constrains all
// DoF, so structural complexity stays well above
// Icp/PointToPlaneMinComplexity and the low-complexity fallback does NOT
// fire. PCL has no 2D PointToPlane estimator (TransformationEstimation2D
// is point-to-point only), so the 2D L-corner case is libpointmatcher-only;
// the 3D room corner case runs on both PCL and libpointmatcher. CCCoreLib
// has no PointToPlane support and is skipped on both.
// =====================================================================
namespace {
class CornerTest : public ::testing::TestWithParam<RegistrationIcp::IcpStrategy>
{
protected:
RegistrationIcp::IcpStrategy strategy() const { return GetParam(); }
};
} // namespace
TEST_P(CornerTest, RoomCorner3D_PointToPlane_RecoversFullTransform)
{
const LaserScan from = makeScan(makeRoomCorner3D());
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.05f, 0.05f, 0.05f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
// Independent sample (different seed) -- mirrors the corridor-test
// model so the recovery tolerance reflects the per-point voxel + jitter
// noise floor, not a degenerate identity-match.
const LaserScan to = util3d::transformLaserScan(
makeScan(makeRoomCorner3D(/*length=*/2.0f, /*pointsPerSurface=*/500,
/*surfaceJitter=*/0.005f, /*seed=*/0xBA5EBA11)),
truth.inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to,
corridorIcpParams6DoF(strategy()), Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_GT(info.icpStructuralComplexity, 0.02f)
<< "expected room corner to stay above low-complexity threshold; got "
<< info.icpStructuralComplexity;
EXPECT_LT(t.getDistance(truth), 1e-2f)
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), 5e-3f);
}
INSTANTIATE_TEST_SUITE_P(
RoomCorner3D,
CornerTest,
::testing::ValuesIn(pointToPlaneCapableStrategies()),
[](const ::testing::TestParamInfo<RegistrationIcp::IcpStrategy> & info)
{
return std::string(RegistrationIcp::strategyName(info.param));
});
TEST(RegistrationIcpTest, RoomCorner2D_PointToPlane_RecoversFullTransform)
{
// libpointmatcher only -- PCL's TransformationEstimation2D is
// point-to-point and crashes mid-iteration when fed normals; see
// makeCorridor2D / util3d_registration.cpp for context.
if(defaultIcpStrategy() != RegistrationIcp::kIcpPointMatcher)
{
GTEST_SKIP() << "2D PointToPlane requires libpointmatcher";
}
const LaserScan from = makeRoomCorner2D();
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.05f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(
makeRoomCorner2D(/*length=*/2.0f, /*jitter=*/0.005f,
/*pointsPerLine=*/500, /*seed=*/0xBA5EBA11),
truth.inverse());
RegistrationInfo info;
const Transform t = runIcp(RegistrationIcp::kIcpPointMatcher, from, to,
corridorIcpParams(RegistrationIcp::kIcpPointMatcher),
Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_GT(info.icpStructuralComplexity, 0.02f)
<< "expected room corner to stay above low-complexity threshold; got "
<< info.icpStructuralComplexity;
EXPECT_LT(t.getDistance(truth), 1e-2f)
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), 5e-3f);
}
// =====================================================================
// Parameterized tests -- run once per available ICP backend
// =====================================================================
TEST_P(RegistrationIcpStrategyTest, SameScanWithIdentityGuessRecoversIdentity)
{
const LaserScan scan = makeScan(makeRandomCloud());
RegistrationInfo info;
const Transform t = runIcp(strategy(), scan, scan, {}, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << "rejectedMsg=" << info.rejectedMsg;
const Transform I = Transform::getIdentity();
EXPECT_LT(t.getDistance(I), tol());
EXPECT_LT(t.getAngle(I), tol());
}
TEST_P(RegistrationIcpStrategyTest, RecoversSmallTranslation)
{
const LaserScan from = makeScan(makeRandomCloud());
const Transform truth(0.05f, 0.03f, -0.02f, 0.0f, 0.0f, 0.0f);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, {}, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), tol())
<< "got=" << t.prettyPrint() << " truth=" << truth.prettyPrint();
EXPECT_LT(t.getAngle(truth), tol());
}
TEST_P(RegistrationIcpStrategyTest, RecoversSmallRotation)
{
const LaserScan from = makeScan(makeRandomCloud());
const float yaw = 5.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, {}, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), tol());
EXPECT_LT(t.getAngle(truth), tol());
}
TEST_P(RegistrationIcpStrategyTest, RecoversCombinedSmallMotion)
{
const LaserScan from = makeScan(makeRandomCloud());
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const float pitch = 2.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.04f, -0.02f, 0.01f, 0.0f, pitch, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, {}, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), tol());
EXPECT_LT(t.getAngle(truth), tol());
}
TEST_P(RegistrationIcpStrategyTest, NonIdentityGuessShortensCorrection)
{
// 8 cm + 10 deg motion. With an identity guess ICP has to make the full
// correction; with `truth` as guess it barely moves. Comparing the
// reported ICP correction magnitudes proves the guess is being used.
const LaserScan from = makeScan(makeRandomCloud());
const float yaw = 10.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.08f, 0.05f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
RegistrationInfo noGuess;
const Transform tNo = runIcp(strategy(), from, to, {}, Transform::getIdentity(), &noGuess);
RegistrationInfo withGuess;
const Transform tYes = runIcp(strategy(), from, to, {}, truth, &withGuess);
ASSERT_FALSE(tNo.isNull()) << noGuess.rejectedMsg;
ASSERT_FALSE(tYes.isNull()) << withGuess.rejectedMsg;
EXPECT_LT(tNo.getDistance(truth), tol());
EXPECT_LT(tYes.getDistance(truth), tol());
EXPECT_LT(tNo.getAngle(truth), tol());
EXPECT_LT(tYes.getAngle(truth), tol());
EXPECT_LT(withGuess.icpTranslation, 0.1f * noGuess.icpTranslation)
<< "noGuess.icpTranslation=" << noGuess.icpTranslation
<< " withGuess.icpTranslation=" << withGuess.icpTranslation;
EXPECT_LT(withGuess.icpRotation, 0.1f * noGuess.icpRotation)
<< "noGuess.icpRotation=" << noGuess.icpRotation
<< " withGuess.icpRotation=" << withGuess.icpRotation;
// Iteration count: PCL and libpointmatcher populate it; CCCoreLib leaves
// it at -1 (its Register() doesn't surface the count). Assert only when
// the backend filled the field.
if(noGuess.icpIterations > 0 && withGuess.icpIterations > 0)
{
EXPECT_LT(withGuess.icpIterations, noGuess.icpIterations)
<< "noGuess.icpIterations=" << noGuess.icpIterations
<< " withGuess.icpIterations=" << withGuess.icpIterations;
}
}
// --- Constraint enforcement -------------------------------------------------
TEST_P(RegistrationIcpStrategyTest, Force3DoFZeroesNonPlanarComponents)
{
// CCCoreLib's 3DoF wrapper zeroes the z coordinate of every input
// point (see icp/cccorelib.h), collapsing our 3D blob cloud onto the
// z=0 plane. With 50 blob centroids overlapping in z, CC's KD-tree
// loses its lock and returns near-identity. PCL / libpointmatcher
// constrain the *transformation* to 3DoF without touching the inputs.
if(strategy() == RegistrationIcp::kIcpCCCoreLib)
{
GTEST_SKIP() << "CCCoreLib's 3DoF input-collapse defeats clustered-cloud lock";
}
const LaserScan from = makeScan(makeRandomCloud());
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.04f, -0.02f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kRegForce3DoF()] = "true";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(z), 1e-3f) << "z should be pinned to 0 in 3DoF";
EXPECT_LT(std::abs(roll), 1e-3f) << "roll should be pinned to 0 in 3DoF";
EXPECT_LT(std::abs(pitch), 1e-3f) << "pitch should be pinned to 0 in 3DoF";
EXPECT_NEAR(yaw, yawOut, 5e-3f);
}
TEST_P(RegistrationIcpStrategyTest, Force3DoFOn2DScan)
{
// Same Force3DoF semantics as above but the scan is 2D (kXY format).
// Each backend's wrapper should detect the format and switch to a
// 2D-aware transformation estimator: PCL uses
// TransformationEstimation2D; libpointmatcher and CCCoreLib should
// install equivalents.
const LaserScan from = makeRandom2DScan();
const float yaw = 3.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.04f, -0.02f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kRegForce3DoF()] = "true";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(z), 1e-3f) << "z should be pinned to 0 in 3DoF";
EXPECT_LT(std::abs(roll), 1e-3f) << "roll should be pinned to 0 in 3DoF";
EXPECT_LT(std::abs(pitch), 1e-3f) << "pitch should be pinned to 0 in 3DoF";
// 2D scans give ICP one fewer dimension of correspondence info than 3D,
// so the recovered yaw/x/y are a bit looser than the 3D Force3DoF test
// (~6 mrad / 5 mm on PCL+libpointmatcher, ~7 mm on CCCoreLib).
const float bound = 2.0f * tol();
EXPECT_NEAR(yaw, yawOut, bound);
EXPECT_NEAR(0.04f, x, bound);
EXPECT_NEAR(-0.02f, y, bound);
}
TEST_P(RegistrationIcpStrategyTest, Force4DoFZeroesRollAndPitch)
{
// Icp/Force4DoF requires a non-PCL backend (RegistrationIcp warns + falls
// back to false on PCL).
if(strategy() == RegistrationIcp::kIcpPCL)
{
GTEST_SKIP() << "Force4DoF not supported on PCL strategy";
}
const LaserScan from = makeScan(makeRandomCloud());
// Motion with all 6 DoF -- after Force4DoF, only x/y/z/yaw survive.
const float deg = static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.04f, -0.02f, 0.03f, 2.0f*deg, 1.5f*deg, 3.0f*deg);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kIcpForce4DoF()] = "true";
// libpointmatcher only honours force4DOF inside its PointToPlane error
// minimizer, so opt in (rtabmap's default is PointToPlane=true; we
// override to false in baseIcpParams to keep the synthetic random-cloud
// tests off the normal/complexity path).
extra[Parameters::kIcpPointToPlane()] = "true";
extra[Parameters::kIcpPointToPlaneK()] = "10";
extra[Parameters::kIcpPointToPlaneRadius()] = "0.0";
extra[Parameters::kIcpPointToPlaneMinComplexity()] = "0.0";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
float x, y, z, roll, pitch, yawOut;
t.getTranslationAndEulerAngles(x, y, z, roll, pitch, yawOut);
EXPECT_LT(std::abs(roll), 1e-3f) << "roll should be pinned to 0 in 4DoF";
EXPECT_LT(std::abs(pitch), 1e-3f) << "pitch should be pinned to 0 in 4DoF";
// x, y, z, yaw remain free DoFs. Truth had roll/pitch components too,
// so ICP solves for the best 4DoF approximation -- recovered x/y/z/yaw
// won't match truth exactly because the rejected DoFs leak into the
// kept ones (3 cm / 0.6 deg on libpointmatcher, ~4 cm / 2 deg on CC).
const float transBound = strategy() == RegistrationIcp::kIcpCCCoreLib ? 0.05f : 0.03f;
const float yawBound = strategy() == RegistrationIcp::kIcpCCCoreLib ? 0.05f : 1e-2f;
EXPECT_LT(std::abs(x - 0.04f), transBound);
EXPECT_LT(std::abs(y - (-0.02f)), transBound);
EXPECT_LT(std::abs(z - 0.03f), transBound);
EXPECT_NEAR(3.0f * deg, yawOut, yawBound);
}
TEST_P(RegistrationIcpStrategyTest, OvershootingMaxTranslationIsRejected)
{
// MaxTranslation default = 0.2 m. Push 0.3 m -- the correction exceeds
// the bound, so the registrar must return a null transform and report
// the rejection.
const LaserScan from = makeScan(makeRandomCloud());
const Transform truth(0.30f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kIcpMaxTranslation()] = "0.2";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
EXPECT_TRUE(t.isNull())
<< "got=" << t.prettyPrint()
<< " rejectedMsg=" << info.rejectedMsg;
EXPECT_FALSE(info.rejectedMsg.empty());
}
TEST_P(RegistrationIcpStrategyTest, OvershootingMaxRotationIsRejected)
{
// Apply 15 deg yaw and tighten MaxRotation to 0.1 rad (~5.7 deg) -- ICP
// converges to a correction well above the bound, so the registrar must
// reject. (60 deg with identity guess only converges partially, so the
// correction can land below the guard; 15 deg stays inside the basin
// and converges fully.)
const LaserScan from = makeScan(makeRandomCloud());
const float yaw = 15.0f * static_cast<float>(M_PI) / 180.0f;
const Transform truth(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, yaw);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kIcpMaxRotation()] = "0.1";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
EXPECT_TRUE(t.isNull())
<< "got=" << t.prettyPrint()
<< " rejectedMsg=" << info.rejectedMsg;
EXPECT_FALSE(info.rejectedMsg.empty());
}
// --- Filtering knobs --------------------------------------------------------
TEST_P(RegistrationIcpStrategyTest, VoxelDownsamplingPreservesRecovery)
{
// 5 cm voxel collapses the per-blob neighbourhood (sigma=4 cm) to one
// or two points per cluster, but the cluster centroids survive and
// preserve global correspondences -- ICP still converges.
const LaserScan from = makeScan(makeRandomCloud());
const Transform truth(0.03f, 0.02f, 0.0f, 0.0f, 0.0f,
2.0f * static_cast<float>(M_PI) / 180.0f);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kIcpVoxelSize()] = "0.05";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), 1e-2f);
EXPECT_LT(t.getAngle(truth), 1e-2f);
}
TEST_P(RegistrationIcpStrategyTest, VoxelDownsamplingPreservesRecoveryOn2DScan)
{
// Same as above but on a 2D scan (kXY). Voxel size = 2 cm so the
// tighter 2D blobs (sigma=1 cm) collapse to one point per cluster --
// enough geometry left to recover the planar motion.
const LaserScan from = makeRandom2DScan();
const Transform truth(0.03f, 0.02f, 0.0f, 0.0f, 0.0f,
2.0f * static_cast<float>(M_PI) / 180.0f);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kRegForce3DoF()] = "true";
extra[Parameters::kIcpVoxelSize()] = "0.02";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
// CC settles at ~1.1 cm on the voxelized 2D blobs (vs <2 mm for PCL /
// libpointmatcher) -- voxel-collapsed clusters lose the intra-blob
// shape info CC otherwise leans on. Loosen to 1.5 cm for that backend.
const float distBound = strategy() == RegistrationIcp::kIcpCCCoreLib ? 1.5e-2f : 1e-2f;
EXPECT_LT(t.getDistance(truth), distBound);
EXPECT_LT(t.getAngle(truth), 1e-2f);
}
TEST_P(RegistrationIcpStrategyTest, PointToPlaneVariantRecoversSmallMotion)
{
// CCCoreLib doesn't implement point-to-plane: RegistrationIcp warns and
// falls back to point-to-point on that backend, which is still a valid
// run but doesn't actually exercise the point-to-plane code path. Skip.
if(strategy() == RegistrationIcp::kIcpCCCoreLib)
{
GTEST_SKIP() << "CCCoreLib does not support point-to-plane ICP";
}
const LaserScan from = makeScan(makeRandomCloud());
const Transform truth(0.03f, 0.02f, -0.01f, 0.0f, 0.0f,
3.0f * static_cast<float>(M_PI) / 180.0f);
const LaserScan to = util3d::transformLaserScan(from, truth.inverse());
ParametersMap extra;
extra[Parameters::kIcpPointToPlane()] = "true";
// PointToPlane needs normals; the registrar estimates them on the fly
// with these neighbourhood params.
extra[Parameters::kIcpPointToPlaneK()] = "10";
extra[Parameters::kIcpPointToPlaneRadius()] = "0.0";
// Pure random points have low PCA structural complexity; relax the
// minimum so point-to-plane stays active.
extra[Parameters::kIcpPointToPlaneMinComplexity()] = "0.0";
RegistrationInfo info;
const Transform t = runIcp(strategy(), from, to, extra, Transform::getIdentity(), &info);
ASSERT_FALSE(t.isNull()) << info.rejectedMsg;
EXPECT_LT(t.getDistance(truth), tol());
EXPECT_LT(t.getAngle(truth), tol());
}
INSTANTIATE_TEST_SUITE_P(
Strategies,
RegistrationIcpStrategyTest,
::testing::ValuesIn(availableIcpStrategies()),
[](const ::testing::TestParamInfo<RegistrationIcp::IcpStrategy> & info)
{
return std::string(RegistrationIcp::strategyName(info.param));
});
#ifdef RTABMAP_POINTMATCHER
// ---------------------------------------------------------------------------
// LaserScan <-> libpointmatcher DataPoints round trips
// (corelib/src/icp/libpointmatcher.h)
//
// These guard three bugs that a plain Release build cannot see, and that
// surfaced only as an intermittent macOS CI segfault inside
// RigidTransformation::inPlaceCompute():
//
// 1. A 2D cloud declared a 3-component "normals" descriptor, while
// libpointmatcher rotates that descriptor with the transform's rotation
// block -- 2x2 for a 2D cloud, whose features are x/y/pad. The mismatched
// product reads past the descriptor matrix.
// 2. The fill/read loops indexed getFeatureViewByName("x"), a *1-row* block,
// with rows 1 and 2. Those land on the y/z rows only because the block
// shares the parent matrix' outer stride.
// 3. laserScanFromDP() inferred the LaserScan format from the channel count,
// which cannot tell a 2D scan with normals from a 3D one (kXYINormal and
// kXYZNormal are both 6 channels).
//
// (1) and (2) are out-of-range Eigen accesses, reported here because
// eigen_assert is overridden at the top of this file to throw.
// ---------------------------------------------------------------------------
namespace {
const std::vector<LaserScan::Format> & dpFormats()
{
static const std::vector<LaserScan::Format> formats = {
LaserScan::kXY,
LaserScan::kXYI,
LaserScan::kXYNormal,
LaserScan::kXYINormal,
LaserScan::kXYZ,
LaserScan::kXYZI,
LaserScan::kXYZNormal,
LaserScan::kXYZINormal,
};
return formats;
}
const float kDpIntensity = 7.0f;
const float kDpZ = 0.5f;
// Points along y=1 with normals pointing at -y. Fields go in LaserScan's
// channel order: x, y, [z], [i], [nx, ny, nz]. nz is 0 on a 2D scan, which is
// what computeNormals2D() produces (2D normals stay in the plane).
LaserScan makeDpScan(
LaserScan::Format format,
int numPoints = 4,
const Transform & localTransform = Transform::getIdentity())
{
const bool is2d = LaserScan::isScan2d(format);
const bool hasI = LaserScan::isScanHasIntensity(format);
const bool hasN = LaserScan::isScanHasNormals(format);
cv::Mat data(1, numPoints, CV_32FC(LaserScan::channels(format)));
for(int i=0; i<numPoints; ++i)
{
float * p = data.ptr<float>(0, i);
int k = 0;
p[k++] = 0.1f*float(i);
p[k++] = 1.0f;
if(!is2d) p[k++] = kDpZ;
if(hasI) p[k++] = kDpIntensity;
if(hasN)
{
p[k++] = 0.0f;
p[k++] = -1.0f;
p[k++] = 0.0f;
}
}
return LaserScan(data, 0, 0.0f, format, localTransform);
}
// +90 deg about z plus a (2,3) translation, sized for the cloud: 3x3 for a 2D
// cloud, 4x4 for a 3D one (what eigenMatrixToDim() hands libpointmatcher).
PM::TransformationParameters rotateZ90AndTranslate(int dimp1)
{
PM::TransformationParameters T =
PM::TransformationParameters::Identity(dimp1, dimp1);
T(0,0) = 0.0f; T(0,1) = -1.0f;
T(1,0) = 1.0f; T(1,1) = 0.0f;
T(0,dimp1-1) = 2.0f;
T(1,dimp1-1) = 3.0f;
return T;
}
// The two products TransformationsImpl<T>::RigidTransformation::inPlaceCompute()
// performs on every ICP iteration: homogeneous features by the full transform,
// the "normals" descriptor by its rotation block only.
//
// Spelled out here rather than obtained from
// PM::get().TransformationRegistrar.create("RigidTransformation") for two
// reasons: libpointmatcher's Windows DLL does not export Registrar::create, and
// its own code is compiled with NDEBUG, so the mismatched product would go
// unchecked. Compiled in this file, the same expressions get this file's
// throwing eigen_assert.
DP applyRigidTransformation(const DP & cloud, const PM::TransformationParameters & T)
{
DP out(cloud);
out.features = T * out.features;
if(out.descriptorExists("normals"))
{
const int dim = (int)T.rows()-1;
DP::View normals(out.getDescriptorViewByName("normals"));
normals = T.topLeftCorner(dim, dim) * normals;
}
return out;
}
class DataPointsFormatTest : public ::testing::TestWithParam<LaserScan::Format>
{
protected:
LaserScan::Format format() const { return GetParam(); }
};
} // namespace
// A cloud of D-dimensional points has D+1 feature rows (the homogeneous "pad"
// row) and libpointmatcher rotates with a DxD block, so "normals" must be D
// rows -- not 3 rows regardless of dimension.
TEST_P(DataPointsFormatTest, NormalsDescriptorMatchesFeatureDimension)
{
const LaserScan scan = makeDpScan(format());
const DP cloud = laserScanToDP(scan);
EXPECT_EQ(LaserScan::isScan2d(format())?3:4, (int)cloud.features.rows());
ASSERT_TRUE(cloud.featureExists("pad"));
EXPECT_EQ(1.0f, cloud.features(cloud.features.rows()-1, 0));
ASSERT_EQ(scan.hasNormals(), cloud.descriptorExists("normals"));
if(scan.hasNormals())
{
EXPECT_EQ((int)cloud.features.rows()-1,
(int)cloud.getDescriptorDimension("normals"));
}
EXPECT_EQ(scan.hasIntensity(), cloud.descriptorExists("intensity"));
}
// Convert, rotate the cloud the way every ICP iteration does, convert back.
TEST_P(DataPointsFormatTest, RoundTripThroughRigidTransformation)
{
const LaserScan scan = makeDpScan(format());
const DP cloud = laserScanToDP(scan);
const DP out = applyRigidTransformation(
cloud, rotateZ90AndTranslate((int)cloud.features.rows()));
const LaserScan back = laserScanFromDP(out);
EXPECT_EQ(format(), back.format())
<< "expected " << LaserScan::formatName(format())
<< ", got " << back.formatName();
ASSERT_EQ(scan.size(), back.size());
EXPECT_EQ(LaserScan::isScan2d(format()), back.is2d());
// Point 0 is (0,1[,0.5]): +90 deg -> (-1,0), translated -> (1,3). Its
// normal (0,-1[,0]) only rotates -> (1,0[,0]).
const float * p = back.data().ptr<float>(0, 0);
EXPECT_NEAR(1.0f, p[0], 1e-5f);
EXPECT_NEAR(3.0f, p[1], 1e-5f);
if(!back.is2d())
{
EXPECT_NEAR(kDpZ, p[2], 1e-5f);
}
if(back.hasNormals())
{
const int no = back.getNormalsOffset();
ASSERT_GE(no, 0);
EXPECT_NEAR(1.0f, p[no], 1e-5f);
EXPECT_NEAR(0.0f, p[no+1], 1e-5f);
EXPECT_NEAR(0.0f, p[no+2], 1e-5f);
}
if(back.hasIntensity())
{
EXPECT_NEAR(kDpIntensity, p[back.getIntensityOffset()], 1e-5f);
}
}
// laserScanToDP() bakes the scan's local transform into the points and the
// normals unless told to ignore it. On a 2D cloud the unused nz view aliases
// the feature view, so a stray write would land on x -- hence checking the
// coordinates too, not just the normals.
TEST_P(DataPointsFormatTest, LocalTransformAppliedToPointsAndNormals)
{
const Transform local(1.0f, 2.0f, 0.0f, 0.0f, 0.0f, static_cast<float>(M_PI_2));
const LaserScan scan = makeDpScan(format(), /*numPoints=*/4, local);
const DP baked = laserScanToDP(scan);
const DP raw = laserScanToDP(scan, /*ignoreLocalTransform=*/true);
ASSERT_EQ(scan.size(), (int)baked.features.cols());
ASSERT_EQ(scan.size(), (int)raw.features.cols());
// Point 0 is (0,1[,0.5]) in scan frame -> yaw 90 deg -> (-1,0) -> +(1,2).
EXPECT_NEAR(0.0f, baked.features(0,0), 1e-5f);
EXPECT_NEAR(2.0f, baked.features(1,0), 1e-5f);
EXPECT_NEAR(0.0f, raw.features(0,0), 1e-5f);
EXPECT_NEAR(1.0f, raw.features(1,0), 1e-5f);
if(!LaserScan::isScan2d(format()))
{
EXPECT_NEAR(kDpZ, baked.features(2,0), 1e-5f);
EXPECT_NEAR(kDpZ, raw.features(2,0), 1e-5f);
}
if(scan.hasNormals())
{
// Normal (0,-1) rotates with the local transform -> (1,0).
const DP::ConstView normals(baked.getDescriptorViewByName("normals"));
EXPECT_NEAR(1.0f, normals(0,0), 1e-5f);
EXPECT_NEAR(0.0f, normals(1,0), 1e-5f);
}
}
INSTANTIATE_TEST_SUITE_P(
ScanFormats,
DataPointsFormatTest,
::testing::ValuesIn(dpFormats()),
[](const ::testing::TestParamInfo<LaserScan::Format> & info)
{
return LaserScan::formatName(info.param);
});
TEST(DataPointsTest, EmptyScanGivesEmptyDataPoints)
{
const DP cloud = laserScanToDP(LaserScan());
EXPECT_EQ(0, (int)cloud.features.cols());
EXPECT_TRUE(laserScanFromDP(cloud).isEmpty());
}
// Non-finite points are skipped and the cloud is resized down to what was
// written, descriptors included.
TEST(DataPointsTest, NonFinitePointsAreDropped)
{
const LaserScan valid = makeDpScan(LaserScan::kXYINormal, /*numPoints=*/4);
cv::Mat data = valid.data().clone();
// One point with a NaN coordinate, one with a NaN normal.
data.ptr<float>(0, 1)[0] = std::numeric_limits<float>::quiet_NaN();
data.ptr<float>(0, 2)[valid.getNormalsOffset()] =
std::numeric_limits<float>::quiet_NaN();
const LaserScan scan(data, 0, 0.0f, LaserScan::kXYINormal);
const DP cloud = laserScanToDP(scan);
EXPECT_EQ(2, (int)cloud.features.cols());
EXPECT_EQ(2, (int)cloud.getDescriptorDimension("normals"));
EXPECT_EQ(cloud.features.cols(), cloud.descriptors.cols());
}
#endif // RTABMAP_POINTMATCHER