rtabmap_conversions tests and doc (#1449)

* Initial tests

* more tests

* More in-depth deskew() testing

* slightly less verbose clamping corruption warning

* added tf buffer related tests

* added remaining tests

* Added rosdoc2, improve tests when we require sync of odom stamp and sensor stamp

* cleanup doc

* fixing ci
This commit is contained in:
matlabbe
2026-09-06 17:23:42 -07:00
committed by GitHub
parent 1e6edb4579
commit f77dda2b58
11 changed files with 4456 additions and 77 deletions
+161 -55
View File
@@ -27,6 +27,9 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#include "rtabmap_conversions/MsgConversion.h"
#include <cmath>
#include <limits>
#include <opencv2/highgui/highgui.hpp>
#include <zlib.h>
#include "rclcpp/rclcpp.hpp"
@@ -60,21 +63,46 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
namespace rtabmap_conversions {
void transformToTF(const rtabmap::Transform & transform, tf2::Transform & tfTransform)
bool transformToTF(const rtabmap::Transform & transform, tf2::Transform & tfTransform)
{
if(!transform.isNull())
if(transform.isNull())
{
geometry_msgs::msg::TransformStamped gm = tf2::eigenToTransform(transform.toEigen3d());
//tf2::fromMsg(gm, tfTransform);
}
else
{
tfTransform = tf2::Transform(tf2::Quaternion(0,0,0,0));
// tf2::Transform cannot represent a null transform: it stores its rotation as a
// basis matrix, so there is no equivalent of the all-zero quaternion used by the
// geometry_msgs conversions. Fill it with NaN so that a caller ignoring the
// return value corrupts its results loudly instead of silently carrying on with
// an identity that looks legitimate.
const tf2Scalar nan = std::numeric_limits<tf2Scalar>::quiet_NaN();
tfTransform = tf2::Transform(
tf2::Matrix3x3(nan, nan, nan, nan, nan, nan, nan, nan, nan),
tf2::Vector3(nan, nan, nan));
return false;
}
geometry_msgs::msg::Transform msg;
transformToGeometryMsg(transform, msg);
tf2::fromMsg(msg, tfTransform);
return true;
}
rtabmap::Transform transformFromTF(const tf2::Transform & transform)
{
// transformToTF() poisons its output with NaN for a null transform, as tf2::Transform
// has no null representation of its own. Map that back to a null transform here so the
// two functions round-trip, and so a NaN coming from anywhere else does not silently
// propagate into the rest of the pipeline.
const tf2::Vector3 & origin = transform.getOrigin();
const tf2::Matrix3x3 & basis = transform.getBasis();
bool nan = std::isnan(origin.x()) || std::isnan(origin.y()) || std::isnan(origin.z());
for(int i=0; !nan && i<3; ++i)
{
nan = std::isnan(basis[i].x()) || std::isnan(basis[i].y()) || std::isnan(basis[i].z());
}
if(nan)
{
return rtabmap::Transform();
}
Eigen::Isometry3d eigenTf;
geometry_msgs::msg::Transform gm = tf2::toMsg(transform);
eigenTf = tf2::transformToEigen(gm);
@@ -244,6 +272,7 @@ void rgbdImageToROS(const rtabmap::SensorData & data, rtabmap_msgs::msg::RGBDIma
UERROR("Cannot convert multi-camera data to rgbd image");
return;
}
msg.header = header;
if(data.cameraModels().size() == 1)
{
//rgb+depth
@@ -558,6 +587,13 @@ void infoFromROS(const rtabmap_msgs::msg::Info & info, rtabmap::Statistics & sta
void infoToROS(const rtabmap::Statistics & stats, rtabmap_msgs::msg::Info & info)
{
// Fall back to the statistics' own stamp when the caller left the header unstamped.
// Callers that already stamped it keep their value, which may be a publication time
// unrelated to the data, or the exact input stamp rather than this double-derived one.
if(info.header.stamp.sec == 0 && info.header.stamp.nanosec == 0)
{
info.header.stamp = timestampToROS(stats.stamp());
}
info.ref_id = stats.refImageId();
info.loop_closure_id = stats.loopClosureId();
info.proximity_detection_id = stats.proximityDetectionId();
@@ -829,9 +865,12 @@ rtabmap::CameraModel cameraModelFromROS(
const sensor_msgs::msg::CameraInfo & camInfo,
const rtabmap::Transform & localTransform)
{
// Note: k, r and p are fixed-size arrays in the ROS message, so they are never
// empty and their size is always right. An unset matrix is signalled by all-zero
// content instead: k[0] and p[0] hold the focal length, which is always non-zero
// for a valid calibration, and an unset rectification matrix is all zeros.
cv:: Mat K;
UASSERT(camInfo.k.empty() || camInfo.k.size() == 9);
if(!camInfo.k.empty())
if(camInfo.k[0] != 0.0)
{
K = cv::Mat(3, 3, CV_64FC1);
memcpy(K.data, camInfo.k.data(), 9*sizeof(double));
@@ -858,17 +897,22 @@ rtabmap::CameraModel cameraModelFromROS(
}
}
// R is a rotation matrix, so any of its elements can legitimately be zero: only
// an entirely zero matrix means "not set".
cv:: Mat R;
UASSERT(camInfo.r.empty() || camInfo.r.size() == 9);
if(!camInfo.r.empty())
bool rIsSet = false;
for(size_t i=0; !rIsSet && i<camInfo.r.size(); ++i)
{
rIsSet = camInfo.r[i] != 0.0;
}
if(rIsSet)
{
R = cv::Mat(3, 3, CV_64FC1);
memcpy(R.data, camInfo.r.data(), 9*sizeof(double));
}
cv:: Mat P;
UASSERT(camInfo.p.empty() || camInfo.p.size() == 12);
if(!camInfo.p.empty())
if(camInfo.p[0] != 0.0)
{
P = cv::Mat(3, 4, CV_64FC1);
memcpy(P.data, camInfo.p.data(), 12*sizeof(double));
@@ -942,8 +986,9 @@ void cameraModelToROS(
{
memset(camInfo.p.data(), 0.0, 12*sizeof(double));
if(!model.K_raw().empty()) {
// P = [K | 0]: copying K already sets the homogeneous P(2,2)=1, and the
// fourth column (the Tx/Ty/Tz translation) stays zero for a single camera.
model.K_raw().copyTo(cv::Mat(3,4,CV_64FC1, camInfo.p.data()).colRange(0,3));
camInfo.p.back() = 1.0;
}
}
else
@@ -1352,10 +1397,13 @@ void sensorDataToROS(const rtabmap::SensorData & data, rtabmap_msgs::msg::Sensor
{
pcl::PCLPointCloud2::Ptr cloud = rtabmap::util3d::laserScanToPointCloud2(data.laserScanRaw());
pcl_conversions::moveFromPCL(*cloud, msg.laser_scan);
msg.laser_scan_max_pts = data.laserScanCompressed().maxPoints();
msg.laser_scan_max_range = data.laserScanCompressed().rangeMax();
msg.laser_scan_format = data.laserScanCompressed().format();
transformToGeometryMsg(data.laserScanCompressed().localTransform(), msg.laser_scan_local_transform);
// Describe the scan we just serialized: reading these from laserScanCompressed()
// zeroes them whenever only the raw scan is set, and sensorDataFromROS() then
// fails its format assertion.
msg.laser_scan_max_pts = data.laserScanRaw().maxPoints();
msg.laser_scan_max_range = data.laserScanRaw().rangeMax();
msg.laser_scan_format = data.laserScanRaw().format();
transformToGeometryMsg(data.laserScanRaw().localTransform(), msg.laser_scan_local_transform);
}
if(!data.laserScanCompressed().empty())
{
@@ -1624,10 +1672,17 @@ std::map<std::string, float> odomInfoToStatistics(const rtabmap::OdometryInfo &
stats.insert(std::make_pair("Odometry/ICPStructuralComplexity/", info.reg.icpStructuralComplexity));
stats.insert(std::make_pair("Odometry/ICPStructuralDistribution/", info.reg.icpStructuralDistribution));
stats.insert(std::make_pair("Odometry/ICPCorrespondences/", info.reg.icpCorrespondences));
stats.insert(std::make_pair("Odometry/StdDevLin/", sqrt((float)info.reg.covariance.at<double>(0,0))));
stats.insert(std::make_pair("Odometry/StdDevAng/", sqrt((float)info.reg.covariance.at<double>(5,5))));
stats.insert(std::make_pair("Odometry/VarianceLin/", (float)info.reg.covariance.at<double>(0,0)));
stats.insert(std::make_pair("Odometry/VarianceAng/", (float)info.reg.covariance.at<double>(5,5)));
// RegistrationInfo leaves covariance empty by default, so only read it when the
// expected 6x6 matrix is actually there.
if(info.reg.covariance.type() == CV_64FC1 &&
info.reg.covariance.rows == 6 &&
info.reg.covariance.cols == 6)
{
stats.insert(std::make_pair("Odometry/StdDevLin/", sqrt((float)info.reg.covariance.at<double>(0,0))));
stats.insert(std::make_pair("Odometry/StdDevAng/", sqrt((float)info.reg.covariance.at<double>(5,5))));
stats.insert(std::make_pair("Odometry/VarianceLin/", (float)info.reg.covariance.at<double>(0,0)));
stats.insert(std::make_pair("Odometry/VarianceAng/", (float)info.reg.covariance.at<double>(5,5)));
}
stats.insert(std::make_pair("Odometry/TimeEstimation/ms", info.timeEstimation*1000.0f));
stats.insert(std::make_pair("Odometry/TimeFiltering/ms", info.timeParticleFiltering*1000.0f));
stats.insert(std::make_pair("Odometry/LocalMapSize/", info.localMapSize));
@@ -2832,8 +2887,7 @@ bool deskew_impl(
tf2_ros::Buffer * tfBuffer,
double waitForTransform,
bool slerp,
const rtabmap::Transform & velocity,
double previousStamp)
const rtabmap::Transform & velocity)
{
if(tfBuffer != 0)
{
@@ -2857,12 +2911,6 @@ bool deskew_impl(
return false;
}
if(previousStamp <= 0.0)
{
UERROR("previousStamp should be >0 when constant velocity model is used!");
return false;
}
if(velocity.isNull())
{
UERROR("velocity should be valid when constant velocity model is used!");
@@ -3133,8 +3181,23 @@ bool deskew_impl(
}
else if(lastStamp == firstStamp)
{
UERROR("First and last stamps in the scan are the same (%f) (header=%f)!", timestampFromROS(lastStamp), timestampFromROS(input.header.stamp));
return false;
// There is no time spread across the scan, so there is nothing to correct. This
// happens when the driver doesn't fill the per-point time channel, and also when
// the cloud has already been deskewed: deskewing zeroes that channel to mark it.
// Pass the cloud through unchanged so that deskewing twice is a no-op rather than
// a failure that makes the caller drop the frame.
static bool warned = false;
if(!warned)
{
UWARN("First and last stamps in the scan are the same (%f) (header=%f), the "
"cloud is returned unchanged. Either the time channel is not filled by "
"the driver, or the cloud has already been deskewed. This warning is "
"only shown once.",
timestampFromROS(lastStamp), timestampFromROS(input.header.stamp));
warned = true;
}
output = input;
return true;
}
std::string errorMsg;
if(tfBuffer != 0 &&
@@ -3184,23 +3247,19 @@ bool deskew_impl(
float vx,vy,vz, vroll,vpitch,vyaw;
velocity.getTranslationAndEulerAngles(vx,vy,vz, vroll,vpitch,vyaw);
// We need three poses:
// 1- The pose of base frame in odom frame at first stamp
// 2- The pose of base frame in odom frame at msg stamp
// 3- The pose of base frame in odom frame at last stamp
UASSERT(timestampFromROS(firstStamp) >= previousStamp);
UASSERT(timestampFromROS(lastStamp) > previousStamp);
double dt1 = timestampFromROS(firstStamp) - previousStamp;
double dt2 = timestampFromROS(input.header.stamp) - previousStamp;
double dt3 = timestampFromROS(lastStamp) - previousStamp;
rtabmap::Transform p1(vx*dt1, vy*dt1, vz*dt1, vroll*dt1, vpitch*dt1, vyaw*dt1);
rtabmap::Transform p2(vx*dt2, vy*dt2, vz*dt2, vroll*dt2, vpitch*dt2, vyaw*dt2);
rtabmap::Transform p3(vx*dt3, vy*dt3, vz*dt3, vroll*dt3, vpitch*dt3, vyaw*dt3);
// Integrate the velocity directly from the stamp of the msg, which is the
// frame the deskewed cloud is expressed in. Going through a third, earlier
// reference pose and composing it away would give the same answer for a pure
// translation, but not for a rotation: Transform() scales roll/pitch/yaw
// linearly instead of using the twist exponential, so the composition only
// cancels in the small-angle limit. Keeping dt bounded by the scan duration
// is where that approximation is at its best.
double dt1 = timestampFromROS(firstStamp) - timestampFromROS(input.header.stamp);
double dt3 = timestampFromROS(lastStamp) - timestampFromROS(input.header.stamp);
// First and last poses are relative to stamp of the msg
firstPose = p2.inverse() * p1;
lastPose = p2.inverse() * p3;
firstPose = rtabmap::Transform(vx*dt1, vy*dt1, vz*dt1, vroll*dt1, vpitch*dt1, vyaw*dt1);
lastPose = rtabmap::Transform(vx*dt3, vy*dt3, vz*dt3, vroll*dt3, vpitch*dt3, vyaw*dt3);
}
if(firstPose.isNull())
@@ -3227,6 +3286,7 @@ bool deskew_impl(
output = input;
rclcpp::Time stamp;
bool clampWarned = false; // reported once per cloud, see the clamp below
UTimer processingTime;
if(timeOnColumns)
{
@@ -3272,7 +3332,27 @@ bool deskew_impl(
rtabmap::Transform transform;
if(slerp)
{
transform = firstPose.interpolate((stamp-firstStamp).seconds() / scanTime, lastPose);
// The ordering check only compares the first and last samples, so a stamp
// outside [firstStamp, lastStamp] can slip through. Clamp it: extrapolating
// would throw the point far beyond the sweep.
double ratio = (stamp-firstStamp).seconds() / scanTime;
if(ratio < 0.0 || ratio > 1.0)
{
// Warned once per cloud rather than once per process: the timestamp
// channel is corrupted, which is a serious upstream problem worth
// reporting on every affected scan, but not once per point.
if(!clampWarned)
{
UWARN("A point has a stamp (%f) outside the first (%f) and last (%f) "
"stamps of the scan, its correction is clamped to the closest end "
"of the sweep. The timestamp channel of the input cloud is likely "
"corrupted. Only the first such point of this cloud is reported.",
timestampFromROS(stamp), timestampFromROS(firstStamp), timestampFromROS(lastStamp));
clampWarned = true;
}
ratio = ratio<0.0?0.0:1.0;
}
transform = firstPose.interpolate(float(ratio), lastPose);
}
else
{
@@ -3366,7 +3446,27 @@ bool deskew_impl(
rtabmap::Transform transform;
if(slerp)
{
transform = firstPose.interpolate((stamp-firstStamp).seconds() / scanTime, lastPose);
// The ordering check only compares the first and last samples, so a stamp
// outside [firstStamp, lastStamp] can slip through. Clamp it: extrapolating
// would throw the point far beyond the sweep.
double ratio = (stamp-firstStamp).seconds() / scanTime;
if(ratio < 0.0 || ratio > 1.0)
{
// Warned once per cloud rather than once per process: the timestamp
// channel is corrupted, which is a serious upstream problem worth
// reporting on every affected scan, but not once per point.
if(!clampWarned)
{
UWARN("A point has a stamp (%f) outside the first (%f) and last (%f) "
"stamps of the scan, its correction is clamped to the closest end "
"of the sweep. The timestamp channel of the input cloud is likely "
"corrupted. Only the first such point of this cloud is reported.",
timestampFromROS(stamp), timestampFromROS(firstStamp), timestampFromROS(lastStamp));
clampWarned = true;
}
ratio = ratio<0.0?0.0:1.0;
}
transform = firstPose.interpolate(float(ratio), lastPose);
}
else
{
@@ -3428,16 +3528,15 @@ bool deskew(
double waitForTransform,
bool slerp)
{
return deskew_impl(input, output, fixedFrameId, &tfBuffer, waitForTransform, slerp, rtabmap::Transform(), 0);
return deskew_impl(input, output, fixedFrameId, &tfBuffer, waitForTransform, slerp, rtabmap::Transform());
}
bool deskew(
const sensor_msgs::msg::PointCloud2 & input,
sensor_msgs::msg::PointCloud2 & output,
double previousStamp,
const rtabmap::Transform & velocity)
{
return deskew_impl(input, output, "", 0, 0, true, velocity, previousStamp);
return deskew_impl(input, output, "", 0, 0, true, velocity);
}
@@ -3493,8 +3592,15 @@ transformPointCloud (
Eigen::Vector4f pt_out;
bool max_range_point = false;
int distance_ptr_offset = i*in.point_step + in.fields[dist_idx].offset;
float* distance_ptr = (dist_idx < 0 ? NULL : (float*)(&in.data[distance_ptr_offset]));
// Only touch in.fields[dist_idx] when the "distance" field actually exists:
// indexing with -1 is out of bounds and aborts on a hardened libstdc++.
int distance_ptr_offset = 0;
float* distance_ptr = NULL;
if (dist_idx >= 0)
{
distance_ptr_offset = i*in.point_step + in.fields[dist_idx].offset;
distance_ptr = (float*)(&in.data[distance_ptr_offset]);
}
if (!std::isfinite (pt[0]) || !std::isfinite (pt[1]) || !std::isfinite (pt[2]))
{
if (distance_ptr==NULL || !std::isfinite(*distance_ptr)) // Invalid point