mirror of
https://github.com/introlab/rtabmap_ros.git
synced 2026-10-05 17:27:46 +08:00
* rtabmap_odom tests and doc * opengv note * added ci checks or humble-latest flaky dep cmake errors * Added real data tests for rgbd_odom and stereo_odom * added real data for icp_odometry's deskewing test * fixing json cmake error on lyrical/rolling * test 2d icp odom deskewing branch * first review of existing OdometryROS tests * testing with imu used as guess * tested imu arrivals sync * Fixed odom reset on right pose when guess frame id is used * fixing header errors in ci >=lyrical * Added support for input rgbd_image topic with features for odom, added multicam rgbd_odometry test * Added stereo odom support for features-only frames. Added multicam stereo tests. * forcing latest rtabmap version * updated OdometryROS API * ci: dont build non-latest docker in pull requests * splitting docker jobs * doc edit * Making publish_null_when_lost:=false continous when guess is provided (using guess covariance when we cannot register yet) * updated stereo doc * ficing rolling ci (rviz Ogre header) * Added test coverage of alll rgbd_image callbacks * fixing rolling ci * making docker ci build/run the tests on pull requests * fixing ros2 ci testing * improved sync callback coverage * improving stereo_odometry test coverage * improved icp_odometry test coverage * lyrical voxel_grid ptr error * make multicam tests working as well without opengv * removing deps of missing packages on rolling * PCL empty cloud conversion compiler errors fix * fixing icp_odometry test failure on ci witohut libpointmatcher * fixing nav2 costmap plugin build on lyrical * joining thread when exiting * updating icp test to work the same on pcl 1.15 (lyrical) * Fix parallel tests seg fault --------- Co-authored-by: mathieu86 <[email protected]>
2104 lines
88 KiB
C++
2104 lines
88 KiB
C++
/*
|
|
Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke
|
|
All rights reserved. (BSD-3-Clause, see the repository root.)
|
|
*/
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <tf2_ros/static_transform_broadcaster.hpp>
|
|
|
|
|
|
#include <rtabmap_msgs/msg/odom_info.hpp>
|
|
#include <rtabmap_msgs/msg/sensor_data.hpp>
|
|
|
|
#include <rtabmap_odom/icp_odometry.hpp>
|
|
|
|
#include <rtabmap/core/util3d_registration.h>
|
|
|
|
#include <rtabmap_conversions/PointCloudConversion.h>
|
|
|
|
#include <tf2_msgs/msg/tf_message.hpp>
|
|
|
|
#include <std_srvs/srv/empty.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <limits>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "bag_playback.hpp"
|
|
#include "msg_builders.hpp"
|
|
#include "scan_scenes.hpp"
|
|
#include "node_test_utils.hpp"
|
|
|
|
namespace rtabmap_odom_test {
|
|
|
|
namespace {
|
|
|
|
::testing::Environment * const kRclcppEnv = registerRclcppEnvironment();
|
|
|
|
/// rtabmap_odom marks a pose it does not trust with a 9999 covariance rather than staying silent.
|
|
bool isLost(const nav_msgs::msg::Odometry & odom)
|
|
{
|
|
return odom.pose.covariance[0] >= 9999.0;
|
|
}
|
|
|
|
double translationNorm(const nav_msgs::msg::Odometry & odom)
|
|
{
|
|
const geometry_msgs::msg::Point & p = odom.pose.pose.position;
|
|
return std::sqrt(p.x*p.x + p.y*p.y + p.z*p.z);
|
|
}
|
|
|
|
/// The rotation carried by a geometry_msgs quaternion, in radians.
|
|
double rotationAngleOf(const geometry_msgs::msg::Quaternion & q)
|
|
{
|
|
return 2.0 * std::acos(std::min(1.0, std::fabs(q.w)));
|
|
}
|
|
|
|
double rotationAngle(const nav_msgs::msg::Odometry & odom)
|
|
{
|
|
const geometry_msgs::msg::Quaternion & q = odom.pose.pose.orientation;
|
|
return 2.0 * std::acos(std::min(1.0, std::fabs(q.w)));
|
|
}
|
|
|
|
/**
|
|
* @brief The 2D corner seen from (@p x, @p y), every ray taken at the same instant.
|
|
*
|
|
* The skewed scans further down bend the walls with the robot's motion; this one does
|
|
* not, so it is what a sweep taken from a standstill looks like. @p withIntensities adds
|
|
* the channel a real lidar reports alongside the range, which is what decides whether the
|
|
* node carries the scan as PointXYZI or as PointXYZ.
|
|
*/
|
|
sensor_msgs::msg::LaserScan cornerScan(
|
|
double stamp, double x = 0.0, double y = 0.0, bool withIntensities = false,
|
|
float sweep = 0.0f)
|
|
{
|
|
sensor_msgs::msg::LaserScan scan;
|
|
scan.header.frame_id = "lidar";
|
|
scan.header.stamp = stampOf(stamp);
|
|
scan.angle_min = -1.0f;
|
|
scan.angle_max = 1.0f;
|
|
scan.angle_increment = 0.01f;
|
|
scan.scan_time = 0.1f;
|
|
scan.range_min = 0.1f;
|
|
scan.range_max = 30.0f;
|
|
const size_t rays = size_t((scan.angle_max - scan.angle_min) / scan.angle_increment) + 1;
|
|
// Zero unless the caller wants the per-ray stamps deskewing needs.
|
|
scan.time_increment = rays > 1 ? sweep / float(rays - 1) : 0.0f;
|
|
scan.ranges.resize(rays);
|
|
for(size_t i=0; i<rays; ++i)
|
|
{
|
|
scan.ranges[i] = corner2DRange(x, y, scan.angle_min + double(i)*scan.angle_increment);
|
|
}
|
|
if(withIntensities)
|
|
{
|
|
// Flat, but present: the node only looks at whether the channel is there.
|
|
scan.intensities.assign(rays, 100.0f);
|
|
}
|
|
return scan;
|
|
}
|
|
|
|
/// @p cloud rearranged into @p width columns, the shape a spinning lidar publishes: one
|
|
/// row per ring, and the node reads a full sweep's size off those dimensions.
|
|
sensor_msgs::msg::PointCloud2 organized(sensor_msgs::msg::PointCloud2 cloud, uint32_t width)
|
|
{
|
|
const uint32_t points = cloud.width * cloud.height;
|
|
cloud.width = width;
|
|
cloud.height = points / width;
|
|
cloud.row_step = cloud.point_step * cloud.width;
|
|
cloud.data.resize(size_t(cloud.row_step) * cloud.height);
|
|
return cloud;
|
|
}
|
|
|
|
/// Every value @p cloud carries in @p field, in point order; empty if it has no such field.
|
|
std::vector<float> fieldValues(
|
|
const sensor_msgs::msg::PointCloud2 & cloud, const std::string & field)
|
|
{
|
|
std::vector<float> values;
|
|
for(const sensor_msgs::msg::PointField & f : cloud.fields)
|
|
{
|
|
if(f.name == field && f.datatype == sensor_msgs::msg::PointField::FLOAT32)
|
|
{
|
|
for(size_t i=0; i<size_t(cloud.width)*cloud.height; ++i)
|
|
{
|
|
float value = 0.0f;
|
|
memcpy(&value, &cloud.data[i*cloud.point_step + f.offset], sizeof(float));
|
|
values.push_back(value);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
/// How many of @p values are above zero.
|
|
size_t countPositive(const std::vector<float> & values)
|
|
{
|
|
return size_t(std::count_if(values.begin(), values.end(),
|
|
[](float value) { return value > 0.0f; }));
|
|
}
|
|
|
|
/**
|
|
* @brief The share of @p cloud's points that still sit within @p maxDistance of @p reference.
|
|
*
|
|
* Deskewing moves points along the sweep rather than adding or removing them, and
|
|
* voxelizing the result renumbers whatever is left, so the two clouds cannot be compared
|
|
* index by index. This is RTAB-Map's own correspondence count, which does not care about
|
|
* the ordering: 1.0 means the two clouds are on top of each other, and anything less is
|
|
* how much of one moved away from the other.
|
|
*/
|
|
double correspondenceRatio(
|
|
const sensor_msgs::msg::PointCloud2 & cloud,
|
|
const sensor_msgs::msg::PointCloud2 & reference,
|
|
double maxDistance)
|
|
{
|
|
pcl::PointCloud<pcl::PointXYZ>::Ptr source(new pcl::PointCloud<pcl::PointXYZ>);
|
|
pcl::PointCloud<pcl::PointXYZ>::Ptr target(new pcl::PointCloud<pcl::PointXYZ>);
|
|
rtabmap_conversions::fromPointCloud2Msg(cloud, *source);
|
|
rtabmap_conversions::fromPointCloud2Msg(reference, *target);
|
|
if(source->empty() || target->empty())
|
|
{
|
|
return 0.0;
|
|
}
|
|
|
|
double variance = 0.0;
|
|
int correspondences = 0;
|
|
rtabmap::util3d::computeVarianceAndCorrespondences(
|
|
source, target, maxDistance, variance, correspondences, false);
|
|
return double(correspondences) / double(source->size());
|
|
}
|
|
|
|
class IcpOdometryTest : public NodeTest
|
|
{
|
|
protected:
|
|
/// The sensor has to be connected to frame_id in TF before the first frame arrives.
|
|
void publishSensorTf(const std::string & sensorFrame = "lidar")
|
|
{
|
|
staticTf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(*helper());
|
|
geometry_msgs::msg::TransformStamped tf;
|
|
tf.header.stamp = helper()->now();
|
|
tf.header.frame_id = "base_link";
|
|
tf.child_frame_id = sensorFrame;
|
|
tf.transform.rotation.w = 1.0;
|
|
staticTf_->sendTransform(tf);
|
|
}
|
|
|
|
/// Calls an Empty service the node advertises under its own name.
|
|
bool callEmptyService(const std::string & name)
|
|
{
|
|
rclcpp::Client<std_srvs::srv::Empty>::SharedPtr client =
|
|
helper()->create_client<std_srvs::srv::Empty>("/icp_odometry/" + name);
|
|
if(!spinUntil([&]() { return client->service_is_ready(); }))
|
|
{
|
|
return false;
|
|
}
|
|
std::shared_future<std_srvs::srv::Empty::Response::SharedPtr> future =
|
|
client->async_send_request(
|
|
std::make_shared<std_srvs::srv::Empty::Request>()).future.share();
|
|
return spinUntil([&]() {
|
|
return future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; });
|
|
}
|
|
|
|
std::shared_ptr<rtabmap_odom::ICPOdometry> makeNode(
|
|
std::vector<rclcpp::Parameter> params = {}, const std::string & name = "")
|
|
{
|
|
// Defaults first, so a test that passes the same parameter overrides them.
|
|
//
|
|
// always_process_most_recent_frame:=false is what the node itself recommends for
|
|
// data that arrives faster than its stamps: these tests publish a whole sequence
|
|
// back to back with stamps a tenth of a second apart, and when the executor is
|
|
// slow enough that two of them land in the same spin -- a loaded CI runner, a
|
|
// single core -- the node drops the second as a replay glitch and the test waits
|
|
// for a message that will never come. It also keeps processing on the calling
|
|
// thread instead of the node's worker, which is what makes these tests observable
|
|
// at all: the odometry is finished by the time the publish returns.
|
|
std::vector<rclcpp::Parameter> all = {
|
|
rclcpp::Parameter("frame_id", "base_link"),
|
|
rclcpp::Parameter("publish_tf", false),
|
|
rclcpp::Parameter("always_process_most_recent_frame", false),
|
|
};
|
|
all.insert(all.end(), params.begin(), params.end());
|
|
rclcpp::NodeOptions options;
|
|
options.parameter_overrides(all);
|
|
if(!name.empty())
|
|
{
|
|
// A test that runs two nodes at once has to keep their names and their odom
|
|
// topics apart, or they publish over each other.
|
|
options.arguments({"--ros-args", "-r", "__node:=" + name,
|
|
"-r", "odom:=odom_" + name,
|
|
"-r", "odom_info:=odom_info_" + name,
|
|
"-r", "odom_sensor_data/raw:=odom_sensor_data_" + name + "/raw",
|
|
"-r", "odom_filtered_input_scan:=odom_filtered_input_scan_" + name});
|
|
}
|
|
return addNode(std::make_shared<rtabmap_odom::ICPOdometry>(options));
|
|
}
|
|
|
|
/**
|
|
* @brief Publishes the recorded TF history, then hands the clouds over one at a time.
|
|
*
|
|
* All of TF goes out first, so every lookup the node makes is already in the buffer:
|
|
* the recording covers each sweep from end to end (see test/data/README.md), and
|
|
* replaying it up front removes any race between TF arriving and a cloud being
|
|
* processed. The clouds keep their recorded stamps and frame -- os_sensor, which TF
|
|
* ties back to base_link through the rig's rotating joint.
|
|
*/
|
|
|
|
// -----------------------------------------------------------------------
|
|
// A 2D lidar on a robot driving at a corner, for the LaserScan deskewing path.
|
|
// -----------------------------------------------------------------------
|
|
|
|
static constexpr double kScanSweep = 0.1; ///< first ray to last, seconds
|
|
static constexpr double kFirstScan = 1.0; ///< stamp of the first scan
|
|
static constexpr double kSecondScan = 1.5; ///< stamp of the second
|
|
|
|
/**
|
|
* @brief Where the robot is at time @p t, in odom.
|
|
*
|
|
* It drives at 1 m/s through the first sweep and the gap after it, then stops before
|
|
* the second. That difference is the whole point: at a constant speed both sweeps bend
|
|
* by the same amount and even an unskewed registration lands in the right place, so
|
|
* the bug would hide. Braking makes the first scan bent and the second straight.
|
|
*/
|
|
static double robotX(double t)
|
|
{
|
|
const double cruise = kSecondScan - kScanSweep; // stops one sweep early
|
|
return t <= kFirstScan ? 0.0
|
|
: (t < cruise ? (t - kFirstScan) : (cruise - kFirstScan));
|
|
}
|
|
|
|
/// What the odometry should report between the two scan stamps.
|
|
static double trueDisplacement()
|
|
{
|
|
return robotX(kSecondScan) - robotX(kFirstScan);
|
|
}
|
|
|
|
/**
|
|
* @brief One scan of the corner, skewed by the robot's motion during the sweep.
|
|
*
|
|
* Each ray is cast from where the sensor actually was when that ray was taken, which
|
|
* is what a real lidar does and what makes the wall come out bent.
|
|
*/
|
|
sensor_msgs::msg::LaserScan makeSkewedCornerScan(double stamp)
|
|
{
|
|
sensor_msgs::msg::LaserScan scan;
|
|
scan.header.frame_id = "lidar";
|
|
scan.header.stamp = stampOf(stamp);
|
|
scan.angle_min = -1.0f;
|
|
scan.angle_max = 1.0f;
|
|
scan.angle_increment = 0.01f;
|
|
scan.range_min = 0.1f;
|
|
scan.range_max = 30.0f;
|
|
const size_t rays = size_t((scan.angle_max - scan.angle_min) / scan.angle_increment) + 1;
|
|
scan.time_increment = float(kScanSweep / double(rays - 1));
|
|
scan.scan_time = float(kScanSweep);
|
|
scan.ranges.resize(rays);
|
|
for(size_t i=0; i<rays; ++i)
|
|
{
|
|
const double rayTime = stamp + double(i) * scan.time_increment;
|
|
const double angle = scan.angle_min + double(i) * scan.angle_increment;
|
|
scan.ranges[i] = corner2DRange(robotX(rayTime), 0.0, angle);
|
|
}
|
|
return scan;
|
|
}
|
|
|
|
/**
|
|
* @brief Publishes odom -> base_link along that trajectory, plus base_link -> lidar.
|
|
*
|
|
* Sampled at 100 Hz across both sweeps: laser_geometry interpolates between whatever
|
|
* TF holds, and the correction is only as good as the trajectory it can see.
|
|
*/
|
|
bool publishRobotTrajectory()
|
|
{
|
|
staticTf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(*helper());
|
|
geometry_msgs::msg::TransformStamped sensor;
|
|
sensor.header.stamp = helper()->now();
|
|
sensor.header.frame_id = "base_link";
|
|
sensor.child_frame_id = "lidar";
|
|
sensor.transform.rotation.w = 1.0;
|
|
staticTf_->sendTransform(sensor);
|
|
|
|
rclcpp::Publisher<tf2_msgs::msg::TFMessage>::SharedPtr tf =
|
|
helper()->create_publisher<tf2_msgs::msg::TFMessage>("/tf", rclcpp::QoS(200));
|
|
std::shared_ptr<Collector<tf2_msgs::msg::TFMessage>> echo =
|
|
collect<tf2_msgs::msg::TFMessage>("/tf", rclcpp::QoS(200));
|
|
if(!waitForSubscriber(tf, 2))
|
|
{
|
|
return false;
|
|
}
|
|
size_t published = 0;
|
|
for(double t = kFirstScan - 0.1; t <= kSecondScan + kScanSweep + 0.1; t += 0.01)
|
|
{
|
|
geometry_msgs::msg::TransformStamped pose;
|
|
pose.header.stamp = stampOf(t);
|
|
pose.header.frame_id = "odom";
|
|
pose.child_frame_id = "base_link";
|
|
pose.transform.translation.x = robotX(t);
|
|
pose.transform.rotation.w = 1.0;
|
|
tf2_msgs::msg::TFMessage message;
|
|
message.transforms.push_back(pose);
|
|
tf->publish(message);
|
|
if(++published % 10 == 0)
|
|
{
|
|
spinFor(std::chrono::milliseconds(5));
|
|
}
|
|
}
|
|
tfPublisher_ = tf;
|
|
return spinUntil([&]() { return echo->size() >= published; });
|
|
}
|
|
|
|
struct Recording
|
|
{
|
|
std::vector<tf2_msgs::msg::TFMessage> staticTransforms;
|
|
std::vector<tf2_msgs::msg::TFMessage> transforms;
|
|
std::vector<sensor_msgs::msg::PointCloud2> clouds;
|
|
bool valid() const
|
|
{
|
|
return !staticTransforms.empty() && !transforms.empty() && clouds.size() >= 2;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* What the sensor turns between the two scans, whoever predicts it.
|
|
*
|
|
* 60 degrees is chosen: large enough that neither ICP backend finds it from an
|
|
* identity start -- both settle within 0.03 rad of no motion at all -- and clear of
|
|
* the corner scene's own 90 degree symmetry, where a wall matched onto the next wall
|
|
* would be a second, equally good answer.
|
|
*/
|
|
static constexpr double kPredictedTurn = 1.05;
|
|
/// Where the IMU's heading starts; see publishImuTurn() for why it is not zero.
|
|
static constexpr double kImuHeading = 0.2;
|
|
|
|
/// base_link -> imu_link, which the IMU callback requires before it accepts anything.
|
|
void publishImuTf()
|
|
{
|
|
imuTf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(*helper());
|
|
geometry_msgs::msg::TransformStamped sensor;
|
|
sensor.header.stamp = helper()->now();
|
|
sensor.header.frame_id = "base_link";
|
|
sensor.child_frame_id = "imu_link";
|
|
sensor.transform.rotation.w = 1.0;
|
|
imuTf_->sendTransform(sensor);
|
|
}
|
|
|
|
/// One IMU sample, heading @p yaw about z.
|
|
sensor_msgs::msg::Imu imuSample(double stamp, double yaw)
|
|
{
|
|
sensor_msgs::msg::Imu sample;
|
|
sample.header.frame_id = "imu_link";
|
|
sample.header.stamp = stampOf(stamp);
|
|
sample.orientation.z = std::sin(yaw / 2.0);
|
|
sample.orientation.w = std::cos(yaw / 2.0);
|
|
return sample;
|
|
}
|
|
|
|
/**
|
|
* @brief Publishes an IMU turning by kPredictedTurn between @p stamp and @p stamp + 0.1.
|
|
*
|
|
* The heading starts away from zero on purpose: RTAB-Map reads an orientation whose x,
|
|
* y and z are all zero as "not set" and ignores the sample, so an IMU sitting at
|
|
* exactly identity would leave the odometry with nothing to difference against and the
|
|
* test would pass while exercising nothing.
|
|
*
|
|
* The whole history goes out before any scan does: with wait_imu_to_init a frame is
|
|
* held back until an IMU sample at or after its stamp has arrived, and dropped when
|
|
* the next frame arrives without one.
|
|
*/
|
|
bool publishImuTurn(double stamp, double keepTurningTo = 0.0)
|
|
{
|
|
imuTf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(*helper());
|
|
geometry_msgs::msg::TransformStamped sensor;
|
|
sensor.header.stamp = helper()->now();
|
|
sensor.header.frame_id = "base_link";
|
|
sensor.child_frame_id = "imu_link";
|
|
sensor.transform.rotation.w = 1.0;
|
|
imuTf_->sendTransform(sensor);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::Imu>::SharedPtr imu =
|
|
helper()->create_publisher<sensor_msgs::msg::Imu>("imu", rclcpp::QoS(200));
|
|
if(!waitForSubscriber(imu))
|
|
{
|
|
return false;
|
|
}
|
|
const double last = keepTurningTo > 0.0 ? stamp + 0.3 : stamp + 0.15;
|
|
for(double t = stamp - 0.1; t <= last; t += 0.01)
|
|
{
|
|
double turn = t <= stamp ? 0.0
|
|
: (t >= stamp + 0.1 ? kPredictedTurn : kPredictedTurn * (t - stamp) / 0.1);
|
|
if(keepTurningTo > 0.0 && t > stamp + 0.1)
|
|
{
|
|
// Carries on turning after the second frame's stamp, so that a guess built
|
|
// from the newest sample instead of the one at the stamp would show it.
|
|
const double past = std::min(1.0, (t - stamp - 0.1) / 0.2);
|
|
turn = kPredictedTurn + (keepTurningTo - kPredictedTurn) * past;
|
|
}
|
|
const double turned = kImuHeading + turn;
|
|
sensor_msgs::msg::Imu sample;
|
|
sample.header.frame_id = "imu_link";
|
|
sample.header.stamp = stampOf(t);
|
|
sample.orientation.z = std::sin(turned / 2.0);
|
|
sample.orientation.w = std::cos(turned / 2.0);
|
|
imu->publish(sample);
|
|
}
|
|
imuPublisher_ = imu;
|
|
spinFor(std::chrono::milliseconds(200));
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @brief Publishes wheel_odom -> base_link turning by kPredictedTurn, the same motion the
|
|
* IMU reports in publishImuTurn().
|
|
*
|
|
* This is the other way to hand the odometry a prediction: a pose source in TF rather
|
|
* than an orientation on a topic. The node differences it between consecutive scan
|
|
* stamps and passes the result to ICP as the guess.
|
|
*/
|
|
bool publishGuessTurn(double stamp)
|
|
{
|
|
rclcpp::Publisher<tf2_msgs::msg::TFMessage>::SharedPtr tf =
|
|
helper()->create_publisher<tf2_msgs::msg::TFMessage>("/tf", rclcpp::QoS(200));
|
|
std::shared_ptr<Collector<tf2_msgs::msg::TFMessage>> echo =
|
|
collect<tf2_msgs::msg::TFMessage>("/tf", rclcpp::QoS(200));
|
|
if(!waitForSubscriber(tf, 2))
|
|
{
|
|
return false;
|
|
}
|
|
size_t published = 0;
|
|
for(double t = stamp - 0.1; t <= stamp + 0.15; t += 0.01)
|
|
{
|
|
const double turned = t <= stamp ? 0.0
|
|
: (t >= stamp + 0.1 ? kPredictedTurn : kPredictedTurn * (t - stamp) / 0.1);
|
|
geometry_msgs::msg::TransformStamped pose;
|
|
pose.header.stamp = stampOf(t);
|
|
pose.header.frame_id = "wheel_odom";
|
|
pose.child_frame_id = "base_link";
|
|
pose.transform.rotation.z = std::sin(turned / 2.0);
|
|
pose.transform.rotation.w = std::cos(turned / 2.0);
|
|
tf2_msgs::msg::TFMessage message;
|
|
message.transforms.push_back(pose);
|
|
tf->publish(message);
|
|
if(++published % 10 == 0)
|
|
{
|
|
spinFor(std::chrono::milliseconds(5));
|
|
}
|
|
}
|
|
tfPublisher_ = tf;
|
|
return spinUntil([&]() { return echo->size() >= published; });
|
|
}
|
|
|
|
Recording readOusterRecording()
|
|
{
|
|
Recording recording;
|
|
recording.staticTransforms =
|
|
readBagMessages<tf2_msgs::msg::TFMessage>(ousterHalfTurnBag(), "/tf_static");
|
|
recording.transforms =
|
|
readBagMessages<tf2_msgs::msg::TFMessage>(ousterHalfTurnBag(), "/tf");
|
|
recording.clouds = readBagMessages<sensor_msgs::msg::PointCloud2>(
|
|
ousterHalfTurnBag(), "/os_cloud_node/points");
|
|
return recording;
|
|
}
|
|
|
|
/**
|
|
* @brief Replays the recorded TF history, after the node under test exists.
|
|
*
|
|
* Order matters: /tf is a volatile topic, so transforms published before the node's
|
|
* listener has subscribed are simply dropped and every lookup then fails with "TF of
|
|
* received scan cloud is not set". /tf_static survives that (it is transient-local)
|
|
* which makes the mistake look like a half-working tree rather than an empty one.
|
|
*
|
|
* @return false if the node never subscribed
|
|
*/
|
|
bool publishRecordedTf(const Recording & recording)
|
|
{
|
|
staticBroadcaster_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(*helper());
|
|
for(const tf2_msgs::msg::TFMessage & message : recording.staticTransforms)
|
|
{
|
|
staticBroadcaster_->sendTransform(message.transforms);
|
|
}
|
|
tfPublisher_ = helper()->create_publisher<tf2_msgs::msg::TFMessage>(
|
|
"/tf", rclcpp::QoS(200));
|
|
// Subscribe to the same topic, so delivery can be waited on rather than guessed
|
|
// at: a fixed pause is enough on an idle machine and not enough on a loaded one,
|
|
// and a cloud that arrives before the transforms is refused outright.
|
|
std::shared_ptr<Collector<tf2_msgs::msg::TFMessage>> echo =
|
|
collect<tf2_msgs::msg::TFMessage>("/tf", rclcpp::QoS(200));
|
|
if(!waitForSubscriber(tfPublisher_, 2)) // the node's listener, and this echo
|
|
{
|
|
return false;
|
|
}
|
|
// In chunks, spinning in between, rather than all at once: tf2's listener reads
|
|
// /tf on its own thread with a bounded queue, and a burst of a hundred messages
|
|
// overflows it on a machine that cannot drain them -- dropping the oldest, which
|
|
// are exactly the ones covering the first cloud. The whole history still goes out
|
|
// before any cloud does.
|
|
size_t published = 0;
|
|
for(const tf2_msgs::msg::TFMessage & message : recording.transforms)
|
|
{
|
|
tfPublisher_->publish(message);
|
|
if(++published % 10 == 0)
|
|
{
|
|
spinFor(std::chrono::milliseconds(10));
|
|
}
|
|
}
|
|
return spinUntil([&]() { return echo->size() >= recording.transforms.size(); });
|
|
}
|
|
|
|
private:
|
|
std::shared_ptr<tf2_ros::StaticTransformBroadcaster> staticTf_;
|
|
std::shared_ptr<tf2_ros::StaticTransformBroadcaster> staticBroadcaster_;
|
|
std::shared_ptr<tf2_ros::StaticTransformBroadcaster> imuTf_;
|
|
rclcpp::Publisher<sensor_msgs::msg::Imu>::SharedPtr imuPublisher_;
|
|
rclcpp::Publisher<tf2_msgs::msg::TFMessage>::SharedPtr tfPublisher_;
|
|
};
|
|
|
|
/**
|
|
* The first scan initializes odometry rather than registering anything: the pose is the
|
|
* identity and the covariance is RTAB-Map's "not estimated" value, not a real one.
|
|
*/
|
|
/**
|
|
* The first scan initializes odometry rather than registering anything: the pose is the
|
|
* identity, and it is the frame every later pose is relative to.
|
|
*/
|
|
TEST_F(IcpOdometryTest, publishes_an_identity_pose_for_the_first_scan_cloud)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); }));
|
|
|
|
const nav_msgs::msg::Odometry & msg = odom->back();
|
|
EXPECT_EQ("odom", msg.header.frame_id);
|
|
EXPECT_EQ("base_link", msg.child_frame_id);
|
|
EXPECT_NEAR(0.0, msg.pose.pose.position.x, 1e-6);
|
|
EXPECT_NEAR(0.0, msg.pose.pose.position.y, 1e-6);
|
|
EXPECT_NEAR(0.0, msg.pose.pose.position.z, 1e-6);
|
|
}
|
|
|
|
/// A 2D lidar goes in on `scan` instead of `scan_cloud`, and reaches the same odometry.
|
|
TEST_F(IcpOdometryTest, accepts_a_laser_scan)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeLaserScan("lidar", 1.0));
|
|
EXPECT_TRUE(spinUntil([&]() { return !odom->empty(); }));
|
|
}
|
|
|
|
/**
|
|
* The point of the node: a second scan taken from a known offset comes back as that
|
|
* offset in the published odometry.
|
|
*
|
|
* The motion matches the one RTAB-Map's own Icp3DCornerRecoversMotionWithoutGuess uses --
|
|
* about 12 cm spread over three axes. Size matters here: a step much larger than
|
|
* Icp/MaxCorrespondenceDistance (0.1 m by default) leaves ICP with nothing to associate
|
|
* and it recovers nothing at all, which is the behaviour described under "When it loses
|
|
* track" in doc/icp_odometry.md.
|
|
*
|
|
* The scene is a corner, so the motion is fully constrained -- see "Degenerate geometry"
|
|
* for the environments where it is not.
|
|
*/
|
|
TEST_F(IcpOdometryTest, recovers_a_known_motion_between_two_scans)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1; }));
|
|
|
|
// The robot moved by this much, so the corner is seen that much nearer.
|
|
const cv::Point3f motion(0.10f, 0.06f, 0.04f);
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3D(motion)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; }));
|
|
|
|
const nav_msgs::msg::Odometry & msg = odom->back();
|
|
EXPECT_NEAR(motion.x, msg.pose.pose.position.x, 0.01);
|
|
EXPECT_NEAR(motion.y, msg.pose.pose.position.y, 0.01);
|
|
EXPECT_NEAR(motion.z, msg.pose.pose.position.z, 0.01);
|
|
}
|
|
|
|
/// Two steps in a row accumulate, rather than each being reported relative to the last.
|
|
TEST_F(IcpOdometryTest, integrates_successive_motions_into_a_pose)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
for(int i=0; i<3; ++i)
|
|
{
|
|
pub->publish(makeXYZCloud("lidar", 1.0 + 0.1*i, corner3D(cv::Point3f(0.05f*i, 0, 0))));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= size_t(i+1); }));
|
|
}
|
|
|
|
// Three frames at 0, 0.05 and 0.10 m: the pose is the total, not the last step.
|
|
EXPECT_NEAR(0.10, odom->back().pose.pose.position.x, 0.01);
|
|
}
|
|
|
|
/**
|
|
* The scan filters default to RTAB-Map's own Icp parameter values rather than to the zeros the
|
|
* source's member initializers suggest. See "Where these defaults come from" in the doc.
|
|
*/
|
|
TEST_F(IcpOdometryTest, scan_filters_default_to_the_icp_parameter_values)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<rtabmap_odom::ICPOdometry> node = makeNode();
|
|
|
|
EXPECT_NEAR(0.05, node->get_parameter("scan_voxel_size").as_double(), 1e-6);
|
|
EXPECT_EQ(5, node->get_parameter("scan_normal_k").as_int());
|
|
}
|
|
|
|
/// Setting the ROS parameter explicitly takes precedence over the Icp/* value.
|
|
TEST_F(IcpOdometryTest, an_explicit_scan_voxel_size_wins_over_the_icp_parameter)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<rtabmap_odom::ICPOdometry> node =
|
|
makeNode({rclcpp::Parameter("scan_voxel_size", 0.25)});
|
|
|
|
EXPECT_NEAR(0.25, node->get_parameter("scan_voxel_size").as_double(), 1e-6);
|
|
}
|
|
|
|
/// odom_info carries the registration result, and is only built when something subscribes.
|
|
TEST_F(IcpOdometryTest, publishes_odom_info_describing_the_registration)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> info =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(waitForPublisher(info->subscription));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !info->empty(); }));
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3D(cv::Point3f(0.1f, 0.0f, 0.0f))));
|
|
ASSERT_TRUE(spinUntil([&]() { return info->size() >= 2; }));
|
|
|
|
// The second frame registered against the first, so the scan map is populated and
|
|
// correspondences were found.
|
|
const rtabmap_msgs::msg::OdomInfo & msg = *info->messages[1];
|
|
EXPECT_FALSE(msg.lost);
|
|
EXPECT_GT(msg.local_scan_map_size, 0);
|
|
EXPECT_GT(msg.icp_correspondences, 0);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deskewing, against a real rotating lidar: test/data/lidar/ouster_pair.
|
|
//
|
|
// The Ouster sits on a mast that turns on a dynamixel joint while the base stays put, so
|
|
// the sensor moves through its own 0.1 s sweep and the cloud comes off the driver skewed
|
|
// -- points recorded early in the sweep are expressed in a pose the sensor has already
|
|
// left. Deskewing undoes that from TF, and doing it wrong is invisible in a synthetic
|
|
// scene where the sensor is motionless within a sweep.
|
|
//
|
|
// The recording carries the per-point `t` field deskewing needs, plus TF from 0.1 s
|
|
// before the first sweep to past the end of the last one.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The fixture is worthless if the recording is not there, so say so plainly.
|
|
TEST_F(IcpOdometryTest, the_recorded_lidar_pair_is_readable)
|
|
{
|
|
const std::vector<sensor_msgs::msg::PointCloud2> clouds =
|
|
readBagMessages<sensor_msgs::msg::PointCloud2>(
|
|
ousterHalfTurnBag(), "/os_cloud_node/points");
|
|
|
|
ASSERT_EQ(2u, clouds.size()) << "expected two clouds in " << ousterHalfTurnBag();
|
|
EXPECT_EQ("os_sensor", clouds[0].header.frame_id);
|
|
EXPECT_EQ(1024u, clouds[0].width);
|
|
EXPECT_EQ(32u, clouds[0].height);
|
|
|
|
// Deskewing needs a per-point time offset; without this field it refuses the cloud.
|
|
bool hasTime = false;
|
|
for(const sensor_msgs::msg::PointField & field : clouds[0].fields)
|
|
{
|
|
hasTime = hasTime || field.name == "t";
|
|
}
|
|
EXPECT_TRUE(hasTime) << "the cloud has no per-point t field to deskew with";
|
|
}
|
|
|
|
/**
|
|
* deskewing:=true with a fixed frame: the node corrects each sweep against TF before
|
|
* registering it, and both clouds come back as odometry in base_link.
|
|
*/
|
|
TEST_F(IcpOdometryTest, deskews_a_rotating_lidar_sweep_against_tf)
|
|
{
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom_deskewed");
|
|
const Recording recording = readOusterRecording();
|
|
ASSERT_TRUE(recording.valid()) << "could not read " << ousterHalfTurnBag();
|
|
|
|
// guess_frame_id is what selects the TF path: with only two clouds there is no
|
|
// velocity estimate yet, so the constant-velocity fallback would never run. base_link
|
|
// is the rig's root -- the mast turns relative to it, which is the motion to undo.
|
|
makeNode({rclcpp::Parameter("deskewing", true),
|
|
rclcpp::Parameter("guess_frame_id", "base_link"),
|
|
rclcpp::Parameter("scan_cloud_max_points", 65536),
|
|
// The room is metres across and the sweeps start half a turn apart.
|
|
rclcpp::Parameter("scan_voxel_size", 0.2),
|
|
rclcpp::Parameter("Icp/MaxCorrespondenceDistance", "2.0"),
|
|
// Pinned: a build without libpointmatcher defaults it to false.
|
|
rclcpp::Parameter("Icp/PointToPlane", "true"),
|
|
// Raised from 0.2 m, which the uncorrected run walks past and is refused for.
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.5"),
|
|
// The recorded transforms may still be arriving when the first cloud lands.
|
|
rclcpp::Parameter("wait_for_transform", 2.0)},
|
|
"deskewed");
|
|
ASSERT_TRUE(publishRecordedTf(recording));
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(recording.clouds[0]);
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); }))
|
|
<< "the first deskewed sweep produced no odometry";
|
|
pub->publish(recording.clouds[1]);
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; }))
|
|
<< "the second deskewed sweep produced no odometry";
|
|
|
|
EXPECT_EQ("odom", odom->back().header.frame_id);
|
|
EXPECT_EQ("base_link", odom->back().child_frame_id);
|
|
ASSERT_FALSE(isLost(odom->back())) << "lost tracking between the two sweeps";
|
|
|
|
// The ground truth is known: the platform never moves in this recording, only the
|
|
// mast turns, so base_link is where it started and the pose should be the identity.
|
|
// What is left after deskewing half a turn of rotation out of two sweeps is 0.013 m
|
|
// and 0.012 rad with libpointmatcher, 0.09 m and 0.015 rad with PCL -- point to
|
|
// plane either way, which is why the parameter above is not left to its default.
|
|
EXPECT_LT(translationNorm(odom->back()), 0.15)
|
|
<< "the platform never moved; this is too far from the origin";
|
|
EXPECT_LT(rotationAngle(odom->back()), 0.05)
|
|
<< "the platform never turned; this is too far from the origin";
|
|
}
|
|
|
|
/**
|
|
* The same two sweeps with and without deskewing, registered side by side.
|
|
*
|
|
* The platform never moved, so the answer is known: the identity. Corrected, the pair
|
|
* lands 0.013 m and 0.012 rad from it with libpointmatcher and 0.09 m with PCL.
|
|
* Uncorrected, it still registers -- on nearly as many points -- but arrives several
|
|
* times further out. That is the shape of a deskewing bug in the field: not a failure, a
|
|
* quietly worse answer.
|
|
*
|
|
* The correction itself is measured too, on the scan each node republishes on
|
|
* odom_filtered_input_scan. That one does not depend on the backend at all, so it is the
|
|
* assertion that catches a deskewing step that quietly stopped working.
|
|
*
|
|
* At RTAB-Map's default Icp/MaxTranslation of 0.2 m the uncorrected run does not even get
|
|
* that far: libpointmatcher aborts with "limit out of bounds: tr 0.214016/0.2" and the
|
|
* pose comes back unusable. The limit is raised here so that both runs produce a number
|
|
* to compare, which says more than one of them failing.
|
|
*/
|
|
TEST_F(IcpOdometryTest, deskewing_is_what_lets_a_half_turn_pair_register)
|
|
{
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> deskewed =
|
|
collect<nav_msgs::msg::Odometry>("odom_deskewed");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> asRecorded =
|
|
collect<nav_msgs::msg::Odometry>("odom_as_recorded");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> deskewedInfo =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info_deskewed");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> asRecordedInfo =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info_as_recorded");
|
|
std::shared_ptr<Collector<sensor_msgs::msg::PointCloud2>> deskewedScan =
|
|
collect<sensor_msgs::msg::PointCloud2>("odom_filtered_input_scan_deskewed");
|
|
std::shared_ptr<Collector<sensor_msgs::msg::PointCloud2>> asRecordedScan =
|
|
collect<sensor_msgs::msg::PointCloud2>("odom_filtered_input_scan_as_recorded");
|
|
const Recording recording = readOusterRecording();
|
|
ASSERT_TRUE(recording.valid()) << "could not read " << ousterHalfTurnBag();
|
|
|
|
// Identical in every respect but the one parameter.
|
|
makeNode({rclcpp::Parameter("deskewing", true),
|
|
rclcpp::Parameter("guess_frame_id", "base_link"),
|
|
rclcpp::Parameter("scan_cloud_max_points", 65536),
|
|
// The room is metres across and the sweeps start half a turn apart.
|
|
rclcpp::Parameter("scan_voxel_size", 0.2),
|
|
rclcpp::Parameter("Icp/MaxCorrespondenceDistance", "2.0"),
|
|
// Pinned: a build without libpointmatcher defaults it to false.
|
|
rclcpp::Parameter("Icp/PointToPlane", "true"),
|
|
// Raised from 0.2 m, which the uncorrected run walks past and is refused for.
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.5"),
|
|
// The recorded transforms may still be arriving when the first cloud lands.
|
|
rclcpp::Parameter("wait_for_transform", 2.0)},
|
|
"deskewed");
|
|
makeNode({rclcpp::Parameter("deskewing", false),
|
|
rclcpp::Parameter("guess_frame_id", "base_link"),
|
|
rclcpp::Parameter("scan_cloud_max_points", 65536),
|
|
rclcpp::Parameter("scan_voxel_size", 0.2),
|
|
rclcpp::Parameter("Icp/MaxCorrespondenceDistance", "2.0"),
|
|
rclcpp::Parameter("Icp/PointToPlane", "true"),
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.5"),
|
|
rclcpp::Parameter("wait_for_transform", 2.0)},
|
|
"as_recorded");
|
|
ASSERT_TRUE(publishRecordedTf(recording));
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub, 2));
|
|
ASSERT_TRUE(waitForPublisher(deskewedScan->subscription));
|
|
ASSERT_TRUE(waitForPublisher(asRecordedScan->subscription));
|
|
|
|
pub->publish(recording.clouds[0]);
|
|
ASSERT_TRUE(spinUntil([&]() { return !deskewed->empty() && !asRecorded->empty(); }));
|
|
pub->publish(recording.clouds[1]);
|
|
ASSERT_TRUE(spinUntil([&]() {
|
|
return deskewed->size() >= 2 && asRecorded->size() >= 2 &&
|
|
deskewedInfo->size() >= 2 && asRecordedInfo->size() >= 2; }));
|
|
|
|
ASSERT_FALSE(isLost(deskewed->back())) << "the deskewed pair failed to register";
|
|
EXPECT_GT(deskewedInfo->back().icp_inliers_ratio, 0.1f)
|
|
<< "the deskewed pair barely matched itself, so something else is wrong";
|
|
|
|
// The correction itself: the same sweep, as the two nodes handed it to ICP. Half a
|
|
// turn of mast rotation across one sweep moves the far end of it by a good fraction
|
|
// of a metre, and a deskewing step that stopped working would leave these two clouds
|
|
// on top of each other.
|
|
ASSERT_FALSE(deskewedScan->empty()) << "the deskewed run republished no scan";
|
|
ASSERT_FALSE(asRecordedScan->empty()) << "the uncorrected run republished no scan";
|
|
// The control: the measure has to say every point of a cloud corresponds to itself,
|
|
// so a number well below that is the two clouds genuinely having moved apart rather
|
|
// than the measurement being broken.
|
|
EXPECT_NEAR(1.0, correspondenceRatio(deskewedScan->back(), deskewedScan->back(), 0.01),
|
|
1e-6) << "the correspondence measure does not even match a cloud to itself";
|
|
|
|
// How far apart the two are depends on PCL's correspondence estimation, which is not
|
|
// the same from one version to the next -- 0.12 with 1.12 against 0.07 with 1.15 --
|
|
// so the bound is loose. A deskewing step that stopped working would leave the two
|
|
// clouds identical and put this back at 1.0, which is what it is here to catch.
|
|
const double stillTogether =
|
|
correspondenceRatio(deskewedScan->back(), asRecordedScan->back(), 0.01);
|
|
EXPECT_LT(stillTogether, 0.5)
|
|
<< stillTogether*100.0 << "% of the deskewed scan is still within a centimetre "
|
|
"of the uncorrected one, so the correction never reached the cloud";
|
|
|
|
// Both runs register, so this part of the claim is about accuracy rather than
|
|
// survival.
|
|
EXPECT_LT(translationNorm(deskewed->back()), 0.15)
|
|
<< "the platform never moved; the deskewed estimate should say so";
|
|
EXPECT_LT(rotationAngle(deskewed->back()), 0.05)
|
|
<< "the platform never turned; the deskewed estimate should say so";
|
|
if(isLost(asRecorded->back()))
|
|
{
|
|
// It failed outright instead -- an even stronger version of the same claim.
|
|
SUCCEED() << "the uncorrected pair could not be registered at all";
|
|
}
|
|
else
|
|
{
|
|
EXPECT_GT(translationNorm(asRecorded->back()), translationNorm(deskewed->back()) * 1.1)
|
|
<< "deskewing did not improve the translation error (deskewed="
|
|
<< translationNorm(deskewed->back()) << " m, as recorded="
|
|
<< translationNorm(asRecorded->back()) << " m)";
|
|
EXPECT_GT(rotationAngle(asRecorded->back()), rotationAngle(deskewed->back()) * 1.1)
|
|
<< "deskewing did not improve the rotation error (deskewed="
|
|
<< rotationAngle(deskewed->back()) << " rad, as recorded="
|
|
<< rotationAngle(asRecorded->back()) << " rad)";
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* The same turn again, predicted from TF instead of an IMU: guess_frame_id names a pose
|
|
* source, the node differences it between the two scan stamps, and ICP gets the same
|
|
* 0.35 rad guess it got from the IMU. Different input, same prediction.
|
|
*/
|
|
TEST_F(IcpOdometryTest, takes_the_rotation_of_its_guess_from_the_guess_frame)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> info =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("guess_frame_id", "wheel_odom"));
|
|
params.push_back(rclcpp::Parameter("wait_for_transform", 2.0));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(publishGuessTurn(1.0));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); })) << "no odometry for the first scan";
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3DTurned(kPredictedTurn)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2 && info->size() >= 2; }))
|
|
<< "no odometry for the second scan";
|
|
|
|
// The same guess the IMU produced: 0.35 rad of rotation, no translation.
|
|
EXPECT_NEAR(kPredictedTurn, rotationAngleOf(info->back().guess.rotation), 0.01)
|
|
<< "the guess handed to ICP did not come from the guess frame";
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.x, 1e-6);
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.y, 1e-6);
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.z, 1e-6);
|
|
|
|
ASSERT_FALSE(isLost(odom->back())) << "the registration did not converge";
|
|
// The pose is the turn itself, where the IMU version also carries kImuHeading: both
|
|
// seed the first pose from their prediction, and this one starts at the identity.
|
|
EXPECT_NEAR(kPredictedTurn, rotationAngle(odom->back()), 0.02);
|
|
}
|
|
|
|
|
|
/**
|
|
* A frame stamped ahead of every IMU sample in the buffer is held, not processed: the
|
|
* odometry would otherwise register it without the orientation that belongs to it. It
|
|
* comes out as soon as an IMU sample reaches its stamp.
|
|
*/
|
|
TEST_F(IcpOdometryTest, holds_a_frame_until_an_imu_covers_its_stamp)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("wait_imu_to_init", true));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
rclcpp::Publisher<sensor_msgs::msg::Imu>::SharedPtr imu =
|
|
helper()->create_publisher<sensor_msgs::msg::Imu>("imu", rclcpp::QoS(200));
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(waitForSubscriber(imu));
|
|
publishImuTf();
|
|
|
|
// IMU up to 1.0 only...
|
|
for(double t = 0.9; t <= 1.0; t += 0.01)
|
|
{
|
|
imu->publish(imuSample(t, kImuHeading));
|
|
}
|
|
spinFor(std::chrono::milliseconds(200));
|
|
|
|
// ...and a frame stamped after all of it.
|
|
pub->publish(makeXYZCloud("lidar", 1.05, corner3D()));
|
|
spinFor(std::chrono::milliseconds(500));
|
|
EXPECT_TRUE(odom->empty())
|
|
<< "the frame was registered before any IMU covered its stamp";
|
|
|
|
// One sample at or past the frame's stamp releases it.
|
|
imu->publish(imuSample(1.06, kImuHeading));
|
|
EXPECT_TRUE(spinUntil([&]() { return !odom->empty(); }))
|
|
<< "the held frame was never processed once the IMU caught up";
|
|
}
|
|
|
|
/**
|
|
* The orientation handed to the odometry is the one belonging to the frame's stamp, not
|
|
* whatever the IMU has reached by the time the frame is processed. Here the IMU keeps
|
|
* turning well past the second frame -- to 2.0 rad, twice the turn between the scans --
|
|
* and the guess still comes out at the turn the frame saw.
|
|
*/
|
|
TEST_F(IcpOdometryTest, uses_the_orientation_at_the_frame_stamp_not_the_newest_one)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> info =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("wait_imu_to_init", true));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(publishImuTurn(1.0, 2.0));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); }));
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3DTurned(kPredictedTurn)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2 && info->size() >= 2; }));
|
|
|
|
// 1.05, the turn between the two stamps -- not the 2.0 the IMU has reached by then.
|
|
EXPECT_NEAR(kPredictedTurn, rotationAngleOf(info->back().guess.rotation), 0.02)
|
|
<< "the guess did not correspond to the frame's own stamp";
|
|
ASSERT_FALSE(isLost(odom->back())) << "the registration did not converge";
|
|
EXPECT_NEAR(kImuHeading + kPredictedTurn, rotationAngle(odom->back()), 0.02);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2D scan deskewing: a lidar on a robot driving at a corner.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The LaserScan path through icp_odometry, with deskewing against a fixed frame.
|
|
*
|
|
* The robot drives at the corner at 1 m/s and stops just before the second scan, so the
|
|
* first sweep is bent and the second is straight. Deskewed, both describe the same corner
|
|
* and the registration returns the distance actually travelled between the two stamps.
|
|
*/
|
|
TEST_F(IcpOdometryTest, deskews_a_2d_scan_against_a_fixed_frame)
|
|
{
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom_deskewed");
|
|
makeNode({rclcpp::Parameter("deskewing", true),
|
|
rclcpp::Parameter("guess_frame_id", "odom"),
|
|
rclcpp::Parameter("Icp/PointToPlane", "false"),
|
|
rclcpp::Parameter("Icp/CorrespondenceRatio", "0.1"),
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.0"),
|
|
rclcpp::Parameter("Reg/Force3DoF", "true"),
|
|
rclcpp::Parameter("scan_voxel_size", 0.0),
|
|
rclcpp::Parameter("wait_for_transform", 2.0)},
|
|
"deskewed");
|
|
ASSERT_TRUE(publishRobotTrajectory());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeSkewedCornerScan(kFirstScan));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); })) << "the first scan produced nothing";
|
|
pub->publish(makeSkewedCornerScan(kSecondScan));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; })) << "the second scan produced nothing";
|
|
|
|
ASSERT_FALSE(isLost(odom->back())) << "the deskewed pair failed to register";
|
|
// 0.399909 m against a truth of 0.4, and 5e-5 m sideways, repeatable to the last
|
|
// digit. A centimetre of tolerance leaves room for a different ICP backend without
|
|
// leaving room for the 5 cm error an unskewed registration makes on this scene.
|
|
EXPECT_NEAR(trueDisplacement(), odom->back().pose.pose.position.x, 0.01)
|
|
<< "the robot drove " << trueDisplacement() << " m between the two scans";
|
|
EXPECT_NEAR(0.0, odom->back().pose.pose.position.y, 0.01)
|
|
<< "it drove straight at the corner, so there is no sideways motion to find";
|
|
}
|
|
|
|
|
|
/**
|
|
* The same two scans with deskewing off, side by side with a node that has it on.
|
|
*
|
|
* The first sweep is bent by the motion and the second is not, so an uncorrected
|
|
* registration is matching two different shapes and pays for it in the estimate.
|
|
*/
|
|
TEST_F(IcpOdometryTest, deskewing_a_2d_scan_changes_what_is_registered)
|
|
{
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> deskewed =
|
|
collect<nav_msgs::msg::Odometry>("odom_deskewed");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> asScanned =
|
|
collect<nav_msgs::msg::Odometry>("odom_as_scanned");
|
|
const std::vector<rclcpp::Parameter> icp = {
|
|
rclcpp::Parameter("guess_frame_id", "odom"),
|
|
rclcpp::Parameter("Icp/PointToPlane", "false"),
|
|
rclcpp::Parameter("Icp/CorrespondenceRatio", "0.1"),
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.0"),
|
|
rclcpp::Parameter("Reg/Force3DoF", "true"),
|
|
rclcpp::Parameter("scan_voxel_size", 0.0),
|
|
rclcpp::Parameter("wait_for_transform", 2.0)};
|
|
std::vector<rclcpp::Parameter> on = icp, off = icp;
|
|
on.push_back(rclcpp::Parameter("deskewing", true));
|
|
off.push_back(rclcpp::Parameter("deskewing", false));
|
|
makeNode(on, "deskewed");
|
|
makeNode(off, "as_scanned");
|
|
ASSERT_TRUE(publishRobotTrajectory());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub, 2));
|
|
|
|
pub->publish(makeSkewedCornerScan(kFirstScan));
|
|
ASSERT_TRUE(spinUntil([&]() { return !deskewed->empty() && !asScanned->empty(); }));
|
|
pub->publish(makeSkewedCornerScan(kSecondScan));
|
|
ASSERT_TRUE(spinUntil([&]() {
|
|
return deskewed->size() >= 2 && asScanned->size() >= 2; }));
|
|
|
|
ASSERT_FALSE(isLost(deskewed->back()));
|
|
ASSERT_FALSE(isLost(asScanned->back()))
|
|
<< "the uncorrected pair was expected to register, just badly";
|
|
|
|
const double deskewedError =
|
|
std::fabs(deskewed->back().pose.pose.position.x - trueDisplacement());
|
|
const double skewedError =
|
|
std::fabs(asScanned->back().pose.pose.position.x - trueDisplacement());
|
|
|
|
// Measured: 0.0001 m corrected against 0.0499 m uncorrected. That 5 cm is half the
|
|
// 0.1 m the robot covered during the bent sweep, which is what it costs to match a
|
|
// bent corner against a straight one.
|
|
EXPECT_LT(deskewedError, 0.01) << "deskewed estimate is " << deskewed->back().pose.pose.position.x;
|
|
EXPECT_GT(skewedError, 0.02)
|
|
<< "the uncorrected registration was as good as the corrected one, so "
|
|
"deskewing never reached the scan (deskewed error=" << deskewedError
|
|
<< " m, uncorrected error=" << skewedError << " m)";
|
|
EXPECT_GT(skewedError, deskewedError * 5.0) << "deskewing barely improved the estimate";
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IMU: the orientation replaces the rotation of the odometry's prediction.
|
|
//
|
|
// RTAB-Map builds a guess for ICP from a constant-velocity model, and when an IMU with a
|
|
// valid orientation is available it keeps that model's translation but takes the rotation
|
|
// from the IMU ("replace orientation guess with IMU" in Odometry::process). On the second
|
|
// frame there is no velocity yet, so the guess is the IMU's rotation and nothing else --
|
|
// which is exactly what odom_info reports.
|
|
//
|
|
// The IMU topic exists only when wait_imu_to_init is set; without it the node never
|
|
// subscribes and every scan is registered from an identity guess.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The sensor turns 0.35 rad between two scans and an IMU says so: the guess handed to ICP
|
|
* carries that rotation and no translation, and the registration lands on it.
|
|
*/
|
|
TEST_F(IcpOdometryTest, takes_the_rotation_of_its_guess_from_the_imu)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> info =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("wait_imu_to_init", true));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(publishImuTurn(1.0));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); })) << "no odometry for the first scan";
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3DTurned(kPredictedTurn)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2 && info->size() >= 2; }))
|
|
<< "no odometry for the second scan";
|
|
|
|
// The guess is the IMU's change of orientation, exactly: 0.35 rad.
|
|
EXPECT_NEAR(kPredictedTurn, rotationAngleOf(info->back().guess.rotation), 0.01)
|
|
<< "the guess handed to ICP did not come from the IMU";
|
|
// ...and the constant-velocity model contributes nothing to it yet, there being no
|
|
// velocity to speak of after a single frame.
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.x, 1e-6);
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.y, 1e-6);
|
|
EXPECT_NEAR(0.0, info->back().guess.translation.z, 1e-6);
|
|
|
|
ASSERT_FALSE(isLost(odom->back())) << "the registration did not converge";
|
|
// The pose also carries the heading the IMU started from: RTAB-Map seeds the first
|
|
// pose with the IMU orientation, so this is kImuHeading + kPredictedTurn, not kPredictedTurn.
|
|
EXPECT_NEAR(kImuHeading + kPredictedTurn, rotationAngle(odom->back()), 0.02);
|
|
}
|
|
|
|
/**
|
|
* The same two scans with no IMU: the guess carries no rotation at all.
|
|
*
|
|
* ICP then fails to find the turn, on either backend: it settles about 0.01 rad from no
|
|
* motion at all and reports that as a successful registration. How it fails does vary --
|
|
* at smaller turns libpointmatcher trips Icp/MaxTranslation and returns an unusable pose
|
|
* while PCL converges correctly -- so the test asserts only that the turn was not
|
|
* recovered, not the manner of it.
|
|
*/
|
|
TEST_F(IcpOdometryTest, without_an_imu_the_guess_carries_no_rotation)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::OdomInfo>> info =
|
|
collect<rtabmap_msgs::msg::OdomInfo>("odom_info");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); }));
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3DTurned(kPredictedTurn)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2 && info->size() >= 2; }));
|
|
|
|
// Null or identity, but in no case the turn: with no IMU there is nothing to predict
|
|
// a rotation from, and no velocity yet either.
|
|
EXPECT_GT(std::fabs(rotationAngleOf(info->back().guess.rotation) - kPredictedTurn), 0.1)
|
|
<< "the guess carried the turn with no IMU to supply it";
|
|
|
|
// And without it the registration does not find the turn: measured at 0.009 rad on
|
|
// PCL and 0.010 on libpointmatcher, against a real 1.05. Neither reports failure --
|
|
// they settle on "barely moved", which is the quiet way this goes wrong in the field.
|
|
if(!isLost(odom->back()))
|
|
{
|
|
EXPECT_GT(std::fabs(rotationAngle(odom->back()) - kPredictedTurn), 0.5)
|
|
<< "ICP recovered the turn from an identity guess, which would make the "
|
|
"prediction tested above unnecessary";
|
|
}
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// What the node hands downstream, and two parameters that change it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The scan republished on odom_sensor_data is the one ICP registered -- after
|
|
* voxelization -- not the sweep the lidar published. See "Reusing the filtered scan
|
|
* downstream" in doc/icp_odometry.md.
|
|
*/
|
|
TEST_F(IcpOdometryTest, republishes_the_filtered_scan_rather_than_the_input)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_voxel_size", 0.5)); // coarse, on a 4 m corner
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(makeXYZCloud("lidar", 1.0, points));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty() && !data->empty(); }));
|
|
|
|
// 1200 points in, 243 out at a 0.5 m voxel on a 4 m corner.
|
|
EXPECT_GT(data->back().laser_scan.width, 0u) << "no scan was republished at all";
|
|
EXPECT_LT(data->back().laser_scan.width, points.size() / 2)
|
|
<< "the republished scan still has the input's density, so it is the input";
|
|
}
|
|
|
|
/**
|
|
* scan_cloud_is_2d says a cloud carrying a z field is a planar scan after all, so it is
|
|
* registered -- and republished -- as 2D. The scan format says which it was: 3
|
|
* (kXYNormal) against 8 (3D with normals) for the same cloud.
|
|
*/
|
|
TEST_F(IcpOdometryTest, scan_cloud_is_2d_registers_a_cloud_as_a_planar_scan)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> planar =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data_planar/raw");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> volume =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data_volume/raw");
|
|
std::vector<rclcpp::Parameter> flat = icpTestParameters();
|
|
std::vector<rclcpp::Parameter> spatial = icpTestParameters();
|
|
flat.push_back(rclcpp::Parameter("scan_cloud_is_2d", true));
|
|
spatial.push_back(rclcpp::Parameter("scan_cloud_is_2d", false));
|
|
makeNode(flat, "planar");
|
|
makeNode(spatial, "volume");
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub, 2));
|
|
|
|
// A flat corner: two walls, every point at z = 0, but the cloud still carries a z field.
|
|
std::vector<cv::Point3f> points;
|
|
for(int i=0; i<200; ++i)
|
|
{
|
|
points.push_back(cv::Point3f(-2.0f + 0.02f*i, -2.0f, 0.0f));
|
|
points.push_back(cv::Point3f(-2.0f, -2.0f + 0.02f*i, 0.0f));
|
|
}
|
|
pub->publish(makeXYZCloud("lidar", 1.0, points));
|
|
ASSERT_TRUE(spinUntil([&]() { return !planar->empty() && !volume->empty(); }));
|
|
|
|
// LaserScan::Format numbers the 2D layouts 1 to 4 and the 3D ones from 5 up.
|
|
EXPECT_LT(planar->back().laser_scan_format, 5)
|
|
<< "the cloud was registered as 3D despite scan_cloud_is_2d";
|
|
EXPECT_GE(volume->back().laser_scan_format, 5)
|
|
<< "the same cloud should be 3D without the parameter";
|
|
}
|
|
|
|
/**
|
|
* deskewing_slerp interpolates the correction between the ends of the sweep instead of
|
|
* looking TF up for every point -- cheaper, and per the documentation slightly less
|
|
* accurate. It has to land in the same place.
|
|
*/
|
|
TEST_F(IcpOdometryTest, deskewing_slerp_gives_the_same_answer_as_the_per_point_lookup)
|
|
{
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> slerp =
|
|
collect<nav_msgs::msg::Odometry>("odom_slerp");
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> perPoint =
|
|
collect<nav_msgs::msg::Odometry>("odom_perpoint");
|
|
const Recording recording = readOusterRecording();
|
|
ASSERT_TRUE(recording.valid()) << "could not read " << ousterHalfTurnBag();
|
|
|
|
const std::vector<rclcpp::Parameter> common = {
|
|
rclcpp::Parameter("deskewing", true),
|
|
rclcpp::Parameter("guess_frame_id", "base_link"),
|
|
rclcpp::Parameter("scan_cloud_max_points", 65536),
|
|
rclcpp::Parameter("scan_voxel_size", 0.2),
|
|
rclcpp::Parameter("Icp/MaxCorrespondenceDistance", "2.0"),
|
|
rclcpp::Parameter("Icp/MaxTranslation", "0.5"),
|
|
rclcpp::Parameter("wait_for_transform", 2.0)};
|
|
std::vector<rclcpp::Parameter> a = common, b = common;
|
|
a.push_back(rclcpp::Parameter("deskewing_slerp", true));
|
|
b.push_back(rclcpp::Parameter("deskewing_slerp", false));
|
|
makeNode(a, "slerp");
|
|
makeNode(b, "perpoint");
|
|
ASSERT_TRUE(publishRecordedTf(recording));
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub, 2));
|
|
|
|
pub->publish(recording.clouds[0]);
|
|
ASSERT_TRUE(spinUntil([&]() { return !slerp->empty() && !perPoint->empty(); }));
|
|
pub->publish(recording.clouds[1]);
|
|
ASSERT_TRUE(spinUntil([&]() {
|
|
return slerp->size() >= 2 && perPoint->size() >= 2; }));
|
|
|
|
ASSERT_FALSE(isLost(slerp->back())) << "the interpolated deskew failed to register";
|
|
ASSERT_FALSE(isLost(perPoint->back()));
|
|
// 0.0117 m against 0.0131 m, and the rotations agree to four decimals: "slightly less
|
|
// accurate", as documented, and nowhere near a different answer.
|
|
EXPECT_NEAR(translationNorm(perPoint->back()), translationNorm(slerp->back()), 0.01)
|
|
<< "interpolating the correction moved the estimate";
|
|
EXPECT_NEAR(rotationAngle(perPoint->back()), rotationAngle(slerp->back()), 0.01);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The fields a driver puts in its cloud, and the filters the node runs on them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @brief A cloud carrying intensity, normals, both, or neither.
|
|
*
|
|
* Each combination is a different PCL point type inside the node, so a driver that
|
|
* publishes everything its lidar measured is handled by different code from one that
|
|
* publishes bare XYZ. All four describe the same corner, so all four have to end at the
|
|
* same pose. scan_downsampling_step is on throughout: the step is applied in each of the
|
|
* four, and halving the cloud is visible in what comes back out.
|
|
*/
|
|
class IcpOdometryCloudFieldsTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<std::tuple<bool, bool>>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryCloudFieldsTest, recovers_the_motion_whatever_fields_the_cloud_carries)
|
|
{
|
|
const bool intensity = std::get<0>(GetParam());
|
|
const bool normals = std::get<1>(GetParam());
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_downsampling_step", 2));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(makeCloudWithFields("lidar", 1.0, points, intensity, normals));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1 && data->size() >= 1; }));
|
|
|
|
const cv::Point3f motion(0.10f, 0.06f, 0.04f);
|
|
pub->publish(makeCloudWithFields("lidar", 1.1, corner3D(motion), intensity, normals));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; }));
|
|
|
|
const nav_msgs::msg::Odometry & msg = odom->back();
|
|
EXPECT_NEAR(motion.x, msg.pose.pose.position.x, 0.015);
|
|
EXPECT_NEAR(motion.y, msg.pose.pose.position.y, 0.015);
|
|
EXPECT_NEAR(motion.z, msg.pose.pose.position.z, 0.015);
|
|
EXPECT_EQ(points.size()/2, data->front().laser_scan.width)
|
|
<< "scan_downsampling_step:=2 should have kept every second point";
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
OptionalFields,
|
|
IcpOdometryCloudFieldsTest,
|
|
::testing::Combine(::testing::Bool(), ::testing::Bool()),
|
|
[](const ::testing::TestParamInfo<std::tuple<bool, bool>> & info) {
|
|
return std::string(std::get<0>(info.param) ? "intensity" : "no_intensity") +
|
|
(std::get<1>(info.param) ? "_normals" : "_no_normals");
|
|
});
|
|
|
|
/**
|
|
* @brief A cloud that says it is not dense, with and without intensity.
|
|
*
|
|
* is_dense:=false is a driver saying some of these points are NaN -- a ray that hit
|
|
* nothing. The node drops them rather than handing NaNs to ICP.
|
|
*/
|
|
class IcpOdometryDenseTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<bool>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryDenseTest, drops_the_invalid_points_of_a_cloud_that_is_not_dense)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
std::vector<cv::Point3f> points = corner3D();
|
|
const size_t valid = points.size();
|
|
const float nan = std::numeric_limits<float>::quiet_NaN();
|
|
points.insert(points.end(), 100, cv::Point3f(nan, nan, nan));
|
|
sensor_msgs::msg::PointCloud2 cloud =
|
|
makeCloudWithFields("lidar", 1.0, points, GetParam(), false);
|
|
cloud.is_dense = false;
|
|
pub->publish(cloud);
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_EQ(valid, data->back().laser_scan.width)
|
|
<< "the 100 NaN points were registered along with the real ones";
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
OptionalFields,
|
|
IcpOdometryDenseTest,
|
|
::testing::Bool(),
|
|
[](const ::testing::TestParamInfo<bool> & info) {
|
|
return info.param ? "intensity" : "no_intensity";
|
|
});
|
|
|
|
/**
|
|
* An organized cloud -- one row per laser ring -- tells the node how many points a full
|
|
* sweep has, so scan_cloud_max_points does not have to be given at all.
|
|
*/
|
|
TEST_F(IcpOdometryTest, takes_scan_cloud_max_points_from_an_organized_cloud)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(organized(makeXYZCloud("lidar", 1.0, points), 80));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_EQ(int(points.size()), data->back().laser_scan_max_pts);
|
|
}
|
|
|
|
/// A value too small for the cloud that arrived is raised to it rather than believed.
|
|
TEST_F(IcpOdometryTest, raises_scan_cloud_max_points_to_the_size_of_an_organized_cloud)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_cloud_max_points", 10));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(organized(makeXYZCloud("lidar", 1.0, points), 80));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_EQ(int(points.size()), data->back().laser_scan_max_pts);
|
|
}
|
|
|
|
/**
|
|
* scan_range_min and scan_range_max cut the cloud down to a shell around the sensor. The
|
|
* corner spans 2.0 m to 3.5 m from the origin, so a 2.6-3.0 m window keeps part of it.
|
|
*/
|
|
TEST_F(IcpOdometryTest, keeps_only_the_cloud_points_within_the_range_limits)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_range_min", 2.6));
|
|
params.push_back(rclcpp::Parameter("scan_range_max", 3.0));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(makeXYZCloud("lidar", 1.0, points));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_GT(data->back().laser_scan.width, 0u) << "the whole cloud was filtered away";
|
|
EXPECT_LT(data->back().laser_scan.width, points.size())
|
|
<< "nothing outside 2.6-3.0 m was dropped";
|
|
}
|
|
|
|
/**
|
|
* scan_normal_ground_up turns the normals a driver sent toward a viewpoint 10 m above the
|
|
* sensor, so the ground's point up the way ICP expects rather than into the road. Here
|
|
* every normal arrives pointing down, and the scan republished on
|
|
* odom_filtered_input_scan is the one the node registered -- normals included, so it says
|
|
* which way they ended up. The second node is the control: without the parameter the node
|
|
* does not touch them at all.
|
|
*/
|
|
TEST_F(IcpOdometryTest, turns_the_normals_of_a_cloud_toward_a_viewpoint_above)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<sensor_msgs::msg::PointCloud2>> aligned =
|
|
collect<sensor_msgs::msg::PointCloud2>("odom_filtered_input_scan_aligned");
|
|
std::shared_ptr<Collector<sensor_msgs::msg::PointCloud2>> asIs =
|
|
collect<sensor_msgs::msg::PointCloud2>("odom_filtered_input_scan_as_is");
|
|
std::vector<rclcpp::Parameter> up = icpTestParameters();
|
|
up.push_back(rclcpp::Parameter("scan_normal_ground_up", 0.8));
|
|
makeNode(up, "aligned");
|
|
makeNode(icpTestParameters(), "as_is");
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub, 2));
|
|
ASSERT_TRUE(waitForPublisher(aligned->subscription));
|
|
ASSERT_TRUE(waitForPublisher(asIs->subscription));
|
|
|
|
pub->publish(makeCloudWithFields("lidar", 1.0, corner3D(), false, true,
|
|
cv::Point3f(0, 0, -1)));
|
|
ASSERT_TRUE(spinUntil([&]() { return !aligned->empty() && !asIs->empty(); }));
|
|
|
|
const std::vector<float> turned = fieldValues(aligned->back(), "normal_z");
|
|
ASSERT_FALSE(turned.empty()) << "the republished scan carries no normals";
|
|
EXPECT_EQ(turned.size(), countPositive(turned)) << "some normals still point down";
|
|
|
|
const std::vector<float> untouched = fieldValues(asIs->back(), "normal_z");
|
|
ASSERT_EQ(turned.size(), untouched.size());
|
|
EXPECT_EQ(0u, countPositive(untouched))
|
|
<< "the normals were turned over without scan_normal_ground_up being set";
|
|
}
|
|
|
|
/// What goes out on odom_filtered_input_scan is the filtered scan, not the input.
|
|
TEST_F(IcpOdometryTest, publishes_the_scan_it_registered_on_odom_filtered_input_scan)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<sensor_msgs::msg::PointCloud2>> filtered =
|
|
collect<sensor_msgs::msg::PointCloud2>("odom_filtered_input_scan");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_voxel_size", 0.5)); // coarse, on a 4 m corner
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
ASSERT_TRUE(waitForPublisher(filtered->subscription));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(makeXYZCloud("lidar", 1.0, points));
|
|
ASSERT_TRUE(spinUntil([&]() { return !filtered->empty(); }));
|
|
|
|
EXPECT_EQ("lidar", filtered->back().header.frame_id);
|
|
EXPECT_GT(filtered->back().width, 0u) << "nothing was republished at all";
|
|
EXPECT_LT(filtered->back().width, points.size()/2)
|
|
<< "the republished scan still has the input's density, so it is the input";
|
|
}
|
|
|
|
/**
|
|
* @brief Deskewing without a guess frame, on a cloud already in frame_id and on one that
|
|
* is not.
|
|
*
|
|
* With no fixed frame to look the motion up in, the node deskews against its own constant
|
|
* velocity model, which lives in frame_id: a cloud published in the sensor's frame has to
|
|
* be carried into frame_id and back, and one already there is deskewed where it lies. The
|
|
* velocity only exists once two frames have registered, so the correction starts on the
|
|
* third.
|
|
*/
|
|
class IcpOdometryDeskewFrameTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<std::string>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryDeskewFrameTest, deskews_against_its_own_velocity)
|
|
{
|
|
const std::string frame = GetParam();
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("deskewing", true));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
for(int i=0; i<4; ++i)
|
|
{
|
|
pub->publish(makeCloudWithFields(frame, 1.0 + 0.1*i,
|
|
corner3D(cv::Point3f(0.05f*i, 0, 0)), false, false,
|
|
cv::Point3f(0, 0, 1), true));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= size_t(i+1); }))
|
|
<< "no odometry for the cloud at " << (1.0 + 0.1*i) << "s";
|
|
}
|
|
|
|
// Four frames 0.05 m apart, and a sweep short enough that deskewing them barely moves
|
|
// anything: the point is that the correction ran, not that it changed the answer.
|
|
EXPECT_NEAR(0.15, odom->back().pose.pose.position.x, 0.02);
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
CloudFrame,
|
|
IcpOdometryDeskewFrameTest,
|
|
::testing::Values("base_link", "lidar"),
|
|
[](const ::testing::TestParamInfo<std::string> & info) {
|
|
return "in_" + info.param;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The same filters, on the 2D scan topic.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @brief A LaserScan with and without the intensity channel.
|
|
*
|
|
* laser_geometry carries the channel into the projected cloud when the scan has one, and
|
|
* from there the node takes the PointXYZI path rather than the PointXYZ one -- separate
|
|
* code for every filter below.
|
|
*/
|
|
class IcpOdometryScanChannelTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<bool>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryScanChannelTest, recovers_the_motion_whatever_channels_the_scan_carries)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(cornerScan(1.0, 0.0, 0.0, GetParam()));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1; }));
|
|
|
|
pub->publish(cornerScan(1.1, 0.05, 0.0, GetParam()));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; }));
|
|
|
|
EXPECT_NEAR(0.05, odom->back().pose.pose.position.x, 0.02);
|
|
}
|
|
|
|
/// scan_downsampling_step and scan_voxel_size thin the scan before ICP sees it.
|
|
TEST_P(IcpOdometryScanChannelTest, downsamples_and_voxelizes_a_laser_scan)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_downsampling_step", 2));
|
|
params.push_back(rclcpp::Parameter("scan_voxel_size", 0.05));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const sensor_msgs::msg::LaserScan scan = cornerScan(1.0, 0.0, 0.0, GetParam());
|
|
pub->publish(scan);
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_GT(data->back().laser_scan.width, 0u) << "the whole scan was filtered away";
|
|
EXPECT_LE(data->back().laser_scan.width, scan.ranges.size()/2 + 1)
|
|
<< "scan_downsampling_step:=2 alone should have halved it";
|
|
}
|
|
|
|
/// With both scan_normal_k and scan_normal_radius off, the scan goes to ICP as bare points.
|
|
TEST_P(IcpOdometryScanChannelTest, registers_a_laser_scan_without_computing_normals)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_normal_k", 0));
|
|
params.push_back(rclcpp::Parameter("scan_normal_radius", 0.0));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(cornerScan(1.0, 0.0, 0.0, GetParam()));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
// LaserScan::Format 1 is kXY and 2 is kXYI; the layouts with normals are 3 and 4.
|
|
EXPECT_LT(data->back().laser_scan_format, 3)
|
|
<< "normals were computed although both scan_normal_* are off";
|
|
}
|
|
|
|
/// The range limits apply to a 2D scan too: the corner is 3 m away at its nearest.
|
|
TEST_P(IcpOdometryScanChannelTest, keeps_only_the_scan_points_within_the_range_limits)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_range_min", 3.5));
|
|
params.push_back(rclcpp::Parameter("scan_range_max", 6.0));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const sensor_msgs::msg::LaserScan scan = cornerScan(1.0, 0.0, 0.0, GetParam());
|
|
pub->publish(scan);
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
EXPECT_GT(data->back().laser_scan.width, 0u) << "the whole scan was filtered away";
|
|
EXPECT_LT(data->back().laser_scan.width, scan.ranges.size())
|
|
<< "nothing nearer than 3.5 m was dropped";
|
|
EXPECT_NEAR(6.0, data->back().laser_scan_max_range, 1e-3)
|
|
<< "scan_range_max should replace the scan's own 30 m range_max";
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
Channels,
|
|
IcpOdometryScanChannelTest,
|
|
::testing::Bool(),
|
|
[](const ::testing::TestParamInfo<bool> & info) {
|
|
return info.param ? "with_intensities" : "without_intensities";
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// One lidar at a time, and the parameters that come from RTAB-Map's own names.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The node subscribes to both `scan` and `scan_cloud`, but registering a 2D scan against
|
|
* a 3D cloud is meaningless, so whichever topic speaks second is dropped for good.
|
|
*/
|
|
TEST_F(IcpOdometryTest, stops_listening_for_scans_once_clouds_are_arriving)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr cloud =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr scan =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(cloud));
|
|
ASSERT_TRUE(waitForSubscriber(scan));
|
|
|
|
cloud->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1; }));
|
|
|
|
scan->publish(cornerScan(1.1));
|
|
spinFor(std::chrono::milliseconds(300));
|
|
EXPECT_EQ(1u, odom->size()) << "the scan was registered against the cloud";
|
|
EXPECT_EQ(0u, scan->get_subscription_count()) << "the scan subscriber is still up";
|
|
}
|
|
|
|
/// And the other way round: a cloud arriving after scans is dropped instead.
|
|
TEST_F(IcpOdometryTest, stops_listening_for_clouds_once_scans_are_arriving)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr cloud =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr scan =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(cloud));
|
|
ASSERT_TRUE(waitForSubscriber(scan));
|
|
|
|
scan->publish(cornerScan(1.0));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1; }));
|
|
|
|
cloud->publish(makeXYZCloud("lidar", 1.1, corner3D()));
|
|
spinFor(std::chrono::milliseconds(300));
|
|
EXPECT_EQ(1u, odom->size()) << "the cloud was registered against the scan";
|
|
EXPECT_EQ(0u, cloud->get_subscription_count()) << "the cloud subscriber is still up";
|
|
}
|
|
|
|
/**
|
|
* Several of the Icp parameters name a filter the node runs itself, before ICP ever sees the
|
|
* scan. Setting one of those is taken as asking for the node's filter, so the value moves
|
|
* to the matching ros parameter -- see "Where these defaults come from" in the doc.
|
|
*/
|
|
TEST_F(IcpOdometryTest, takes_the_icp_filter_values_as_its_own_scan_parameters)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<rtabmap_odom::ICPOdometry> node = makeNode({
|
|
rclcpp::Parameter("Icp/DownsamplingStep", "2"),
|
|
rclcpp::Parameter("Icp/RangeMin", "0.5"),
|
|
rclcpp::Parameter("Icp/RangeMax", "20.0"),
|
|
rclcpp::Parameter("Icp/PointToPlaneRadius", "0.3"),
|
|
rclcpp::Parameter("Icp/PointToPlaneGroundNormalsUp", "0.8")});
|
|
|
|
EXPECT_EQ(2, node->get_parameter("scan_downsampling_step").as_int());
|
|
EXPECT_NEAR(0.5, node->get_parameter("scan_range_min").as_double(), 1e-6);
|
|
EXPECT_NEAR(20.0, node->get_parameter("scan_range_max").as_double(), 1e-6);
|
|
EXPECT_NEAR(0.3, node->get_parameter("scan_normal_radius").as_double(), 1e-6);
|
|
EXPECT_NEAR(0.8, node->get_parameter("scan_normal_ground_up").as_double(), 1e-6);
|
|
}
|
|
|
|
/**
|
|
* Reg/Strategy picks between visual and ICP registration, and this node only has ICP. A
|
|
* value asking for anything else is overruled rather than obeyed.
|
|
*/
|
|
TEST_F(IcpOdometryTest, registers_with_icp_whatever_reg_strategy_asks_for)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("Reg/Strategy", "0")); // visual only
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeXYZCloud("lidar", 1.0, corner3D()));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 1; }));
|
|
|
|
const cv::Point3f motion(0.10f, 0.06f, 0.04f);
|
|
pub->publish(makeXYZCloud("lidar", 1.1, corner3D(motion)));
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= 2; }));
|
|
|
|
// Nothing but ICP could have recovered this: there is no image to register.
|
|
EXPECT_NEAR(motion.x, odom->back().pose.pose.position.x, 0.01);
|
|
}
|
|
|
|
|
|
/**
|
|
* @brief The four field combinations again, organized and downsampled.
|
|
*
|
|
* An organized cloud keeps its rows through the step, so the node counts what a full
|
|
* sweep holds from the cloud's own dimensions instead of dividing by the step.
|
|
*/
|
|
class IcpOdometryOrganizedFieldsTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<std::tuple<bool, bool>>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryOrganizedFieldsTest, sizes_a_downsampled_organized_cloud_from_its_rows)
|
|
{
|
|
const bool intensity = std::get<0>(GetParam());
|
|
const bool normals = std::get<1>(GetParam());
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_downsampling_step", 2));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
const std::vector<cv::Point3f> points = corner3D();
|
|
pub->publish(organized(
|
|
makeCloudWithFields("lidar", 1.0, points, intensity, normals), 80));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
// 1200 points as 15 rings of 80, every second point of each ring kept: 15 x 40.
|
|
EXPECT_EQ(int(points.size()/2), data->back().laser_scan_max_pts);
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
OptionalFields,
|
|
IcpOdometryOrganizedFieldsTest,
|
|
::testing::Combine(::testing::Bool(), ::testing::Bool()),
|
|
[](const ::testing::TestParamInfo<std::tuple<bool, bool>> & info) {
|
|
return std::string(std::get<0>(info.param) ? "intensity" : "no_intensity") +
|
|
(std::get<1>(info.param) ? "_normals" : "_no_normals");
|
|
});
|
|
|
|
/// With both scan_normal_* off, a cloud reaches ICP as bare points, intensity or not.
|
|
TEST_P(IcpOdometryDenseTest, registers_a_cloud_without_computing_normals)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("scan_normal_k", 0));
|
|
params.push_back(rclcpp::Parameter("scan_normal_radius", 0.0));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeCloudWithFields("lidar", 1.0, corner3D(), GetParam(), false));
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
// LaserScan::Format kXYZ and kXYZI; the layouts with normals are 8 and 9.
|
|
EXPECT_EQ(GetParam() ? 6 : 5, data->back().laser_scan_format)
|
|
<< "normals were computed although both scan_normal_* are off";
|
|
}
|
|
|
|
/**
|
|
* An intensity channel the node cannot read -- anything but float32 -- is dropped rather
|
|
* than misread, and the cloud is registered without it.
|
|
*/
|
|
TEST_F(IcpOdometryTest, ignores_an_intensity_field_it_cannot_read)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<rtabmap_msgs::msg::SensorData>> data =
|
|
collect<rtabmap_msgs::msg::SensorData>("odom_sensor_data/raw");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
sensor_msgs::msg::PointCloud2 cloud =
|
|
makeCloudWithFields("lidar", 1.0, corner3D(), true, false);
|
|
for(sensor_msgs::msg::PointField & field : cloud.fields)
|
|
{
|
|
if(field.name == "intensity")
|
|
{
|
|
field.datatype = sensor_msgs::msg::PointField::UINT8;
|
|
}
|
|
}
|
|
pub->publish(cloud);
|
|
ASSERT_TRUE(spinUntil([&]() { return !data->empty(); }));
|
|
|
|
// kXYZNormal rather than kXYZINormal: the channel was left out.
|
|
EXPECT_EQ(8, data->back().laser_scan_format)
|
|
<< "an intensity channel that is not float32 was carried through anyway";
|
|
}
|
|
|
|
/// A frame TF knows nothing about is an error, not a pose: the frame is dropped.
|
|
TEST_F(IcpOdometryTest, refuses_a_cloud_whose_frame_is_not_in_tf)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::PointCloud2>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::PointCloud2>("scan_cloud", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(makeXYZCloud("unmounted_lidar", 1.0, corner3D()));
|
|
spinFor(std::chrono::milliseconds(500));
|
|
EXPECT_TRUE(odom->empty()) << "a cloud from an unknown frame was registered anyway";
|
|
}
|
|
|
|
/// The same for a 2D scan.
|
|
TEST_F(IcpOdometryTest, refuses_a_scan_whose_frame_is_not_in_tf)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
sensor_msgs::msg::LaserScan scan = cornerScan(1.0);
|
|
scan.header.frame_id = "unmounted_lidar";
|
|
pub->publish(scan);
|
|
spinFor(std::chrono::milliseconds(500));
|
|
EXPECT_TRUE(odom->empty()) << "a scan from an unknown frame was registered anyway";
|
|
}
|
|
|
|
/**
|
|
* @brief Deskewing a 2D scan without a guess frame, from the sensor's frame and from
|
|
* frame_id.
|
|
*
|
|
* The cloud version of this is IcpOdometryDeskewFrameTest above; a scan takes a different
|
|
* route to the same place, because laser_geometry projects it into frame_id first when
|
|
* the sensor is mounted somewhere else, and leaves it alone when it is not.
|
|
*/
|
|
class IcpOdometryScanDeskewFrameTest :
|
|
public IcpOdometryTest,
|
|
public ::testing::WithParamInterface<std::string>
|
|
{
|
|
};
|
|
|
|
TEST_P(IcpOdometryScanDeskewFrameTest, deskews_a_scan_against_its_own_velocity)
|
|
{
|
|
const std::string frame = GetParam();
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::vector<rclcpp::Parameter> params = icpTestParameters();
|
|
params.push_back(rclcpp::Parameter("deskewing", true));
|
|
makeNode(params);
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
for(int i=0; i<4; ++i)
|
|
{
|
|
sensor_msgs::msg::LaserScan scan = cornerScan(1.0 + 0.1*i, 0.05*i, 0.0, false, 0.01f);
|
|
scan.header.frame_id = frame;
|
|
pub->publish(scan);
|
|
ASSERT_TRUE(spinUntil([&]() { return odom->size() >= size_t(i+1); }))
|
|
<< "no odometry for the scan at " << (1.0 + 0.1*i) << "s";
|
|
}
|
|
|
|
EXPECT_NEAR(0.15, odom->back().pose.pose.position.x, 0.02);
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(
|
|
ScanFrame,
|
|
IcpOdometryScanDeskewFrameTest,
|
|
::testing::Values("base_link", "lidar"),
|
|
[](const ::testing::TestParamInfo<std::string> & info) {
|
|
return "in_" + info.param;
|
|
});
|
|
|
|
|
|
/**
|
|
* pause_odom stops the scan topic as well as the cloud one -- the scans keep arriving,
|
|
* and none of them is registered until resume_odom.
|
|
*/
|
|
TEST_F(IcpOdometryTest, pause_and_resume_stop_and_restart_processing_scans)
|
|
{
|
|
publishSensorTf();
|
|
std::shared_ptr<Collector<nav_msgs::msg::Odometry>> odom =
|
|
collect<nav_msgs::msg::Odometry>("odom");
|
|
std::shared_ptr<rtabmap_odom::ICPOdometry> node = makeNode(icpTestParameters());
|
|
|
|
rclcpp::Publisher<sensor_msgs::msg::LaserScan>::SharedPtr pub =
|
|
helper()->create_publisher<sensor_msgs::msg::LaserScan>("scan", 10);
|
|
ASSERT_TRUE(waitForSubscriber(pub));
|
|
|
|
pub->publish(cornerScan(1.0));
|
|
ASSERT_TRUE(spinUntil([&]() { return !odom->empty(); }));
|
|
|
|
ASSERT_TRUE(callEmptyService("pause_odom"));
|
|
const size_t whilePaused = odom->size();
|
|
pub->publish(cornerScan(1.1, 0.05));
|
|
spinFor(std::chrono::milliseconds(500));
|
|
EXPECT_EQ(whilePaused, odom->size()) << "a scan was registered while paused";
|
|
|
|
ASSERT_TRUE(callEmptyService("resume_odom"));
|
|
pub->publish(cornerScan(1.2, 0.10));
|
|
EXPECT_TRUE(spinUntil([&]() { return odom->size() > whilePaused; }));
|
|
}
|
|
|
|
|
|
|
|
} // namespace
|
|
} // namespace rtabmap_odom_test
|