/* Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke All rights reserved. (BSD-3-Clause, see the repository root.) */ #include "node_test_utils.hpp" #include "msg_builders.hpp" #include #include #include #include using namespace rtabmap_util_test; namespace { ::testing::Environment * const kEnv = registerRclcppEnvironment(); } class LidarDeskewingTest : public NodeTest { protected: static constexpr double kSweep = 0.099; ///< first sample to last, seconds static constexpr double kSpeed = 1.0; ///< m/s, straight at the wall static constexpr float kWall = 5.0f; ///< distance to the wall, meters /// Distance travelled since the first sample. Drives both the TF and the skew. static double travelled(double elapsed) { return kSpeed * elapsed; } /// Publishes odom -> lidar following exactly that trajectory. void publishOdomMotion(double startStamp) { rclcpp::Publisher::SharedPtr tfPub = helper()->create_publisher("/tf", rclcpp::QoS(100)); spinFor(std::chrono::milliseconds(100)); // let the node's listener subscribe // Covers exactly the sweep, from the first sample to the last. Nothing beyond: // asking for more than laser_geometry needs would be a regression. for(int i=0; i<=2; ++i) { const double elapsed = kSweep * double(i) / 2.0; geometry_msgs::msg::TransformStamped t; t.header.stamp = stampOf(startStamp + elapsed); t.header.frame_id = "odom"; t.child_frame_id = "lidar"; t.transform.translation.x = travelled(elapsed); t.transform.rotation.w = 1.0; tf2_msgs::msg::TFMessage msg; msg.transforms.push_back(t); tfPub->publish(msg); } spinFor(std::chrono::milliseconds(200)); // let the buffer fill tfPub_ = tfPub; // keep the publisher alive } rclcpp::Publisher::SharedPtr tfPub_; }; TEST_F(LidarDeskewingTest, DeskewsACloudUsingTf) { // The wall is recorded bent because the sensor closes in during the sweep, and TF // carries that same motion. A correct deskew must flatten it back to kWall. addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("fixed_frame_id", "odom"), rclcpp::Parameter("wait_for_transform", 0.2)}))); publishOdomMotion(1000.0); std::shared_ptr> out = collect("input_cloud/deskewed"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("input_cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); const size_t sampleCount = 20; const sensor_msgs::msg::PointCloud2 in = makeSkewedWallScan( "lidar", 1000.0, sampleCount, kSweep, kWall, &travelled); // The input really is bent: the last sample is a full sweep of travel closer. ASSERT_NEAR(readXYZ(in, 0).x, kWall, 1e-4); ASSERT_NEAR(readXYZ(in, sampleCount-1).x, kWall - float(kSpeed*kSweep), 1e-4); pub->publish(in); ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) << "no deskewed cloud published"; const sensor_msgs::msg::PointCloud2 & cloud = out->back(); EXPECT_EQ(cloud.header.frame_id, "lidar") << "output stays in the sensor frame"; ASSERT_EQ(cloud.width, sampleCount); // Every sample must land back on the wall. for(size_t i=0; i(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("fixed_frame_id", "odom"), rclcpp::Parameter("wait_for_transform", 0.2)}))); publishOdomMotion(1000.0); std::shared_ptr> out = collect("input_scan/deskewed"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("input_scan", 10); ASSERT_TRUE(waitForSubscriber(pub)); sensor_msgs::msg::LaserScan scan; scan.header.frame_id = "lidar"; scan.header.stamp = stampOf(1000.0); scan.angle_min = -0.4f; scan.angle_max = 0.4f; scan.angle_increment = 0.05f; scan.range_min = 0.1f; scan.range_max = 30.0f; const size_t rayCount = size_t((scan.angle_max - scan.angle_min) / scan.angle_increment) + 1; scan.time_increment = float(kSweep / double(rayCount - 1)); scan.ranges.resize(rayCount); for(size_t i=0; ipublish(scan); ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) << "no deskewed scan published"; const sensor_msgs::msg::PointCloud2 & cloud = out->back(); EXPECT_EQ(cloud.header.frame_id, "lidar") << "output stays in the sensor frame"; ASSERT_EQ(cloud.width, rayCount); // Without deskewing the last ray would sit a full sweep of travel short of the wall. for(size_t i=0; i(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("fixed_frame_id", "odom"), rclcpp::Parameter("wait_for_transform", 0.0)}))); std::shared_ptr> out = collect("input_cloud/deskewed"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("input_cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); const sensor_msgs::msg::PointCloud2 in = makeXYZCloud("lidar", 1000.0, {{5.0f, 0.0f, 0.0f}, {5.0f, 1.0f, 0.0f}}, true); pub->publish(in); ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) << "the cloud must still be forwarded"; EXPECT_EQ(out->back().data, in.data) << "and forwarded byte for byte, still skewed"; } TEST_F(LidarDeskewingTest, DropsAScanWhenTfIsMissing) { // The 2D scan path does the opposite of the cloud path: it returns early and // publishes nothing when the transform is unavailable. addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("fixed_frame_id", "odom"), rclcpp::Parameter("wait_for_transform", 0.0)}))); std::shared_ptr> out = collect("input_scan/deskewed"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("input_scan", 10); ASSERT_TRUE(waitForSubscriber(pub)); sensor_msgs::msg::LaserScan scan; scan.header.frame_id = "lidar"; scan.header.stamp = stampOf(1000.0); scan.angle_min = -1.0f; scan.angle_max = 1.0f; scan.angle_increment = 0.1f; scan.time_increment = 0.001f; scan.range_min = 0.1f; scan.range_max = 30.0f; scan.ranges.assign(21, 5.0f); pub->publish(scan); spinFor(std::chrono::milliseconds(400)); EXPECT_TRUE(out->empty()) << "the scan path drops the message instead of forwarding it"; }