/* 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 using namespace rtabmap_util_test; namespace { ::testing::Environment * const kEnv = registerRclcppEnvironment(); /** * @brief A dense ground plane at z=0 plus a vertical wall in front of it. * * The 0.05 m spacing matters: the segmentation clusters points with * Grid/ClusterRadius (0.1 m by default) and drops clusters below * Grid/MinClusterSize (10), so a sparser cloud is discarded entirely. */ std::vector groundAndWall() { std::vector points; for(int i=0; i<=20; ++i) // ground: 1 m x 1 m at z=0 { for(int j=0; j<=20; ++j) { points.push_back(cv::Point3f(0.3f + 0.05f*i, -0.5f + 0.05f*j, 0.0f)); } } for(int j=0; j<=20; ++j) // wall: vertical, 1 m wide, 0.75 m tall { for(int k=1; k<=15; ++k) { points.push_back(cv::Point3f(1.4f, -0.5f + 0.05f*j, 0.05f*k)); } } return points; } /** * @brief A plane that is horizontal in the map frame, given a base frame pitched by * @p pitch. In the base frame it therefore rises with x: z = x * tan(pitch). */ std::vector planeLevelInMapFrame(double pitch) { std::vector points; for(int i=0; i<=20; ++i) { const float x = 0.8f + 0.05f*i; for(int j=0; j<=20; ++j) { points.push_back(cv::Point3f(x, -0.5f + 0.05f*j, x * float(std::tan(pitch)))); } } return points; } /// Two flat 25-point patches: one about 0.5 m from the sensor, one about 3 m away. std::vector nearAndFarPatches() { std::vector points; for(int i=0; i<5; ++i) { for(int j=0; j<5; ++j) { points.push_back(cv::Point3f(0.4f + 0.05f*i, -0.1f + 0.05f*j, 0.0f)); points.push_back(cv::Point3f(2.9f + 0.05f*i, -0.1f + 0.05f*j, 0.0f)); } } return points; } /// Smallest and largest x in a cloud, to tell the near patch from the far one. std::pair xExtent(const sensor_msgs::msg::PointCloud2 & cloud) { float lo = std::numeric_limits::max(); float hi = -std::numeric_limits::max(); for(size_t i=0; i(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("wait_for_transform", 0.1)}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, groundAndWall())); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })) << "both ground and obstacles must be published"; EXPECT_GT(ground->back().width, 0u) << "the flat points must be classified as ground"; EXPECT_GT(obstacles->back().width, 0u) << "the wall must be classified as obstacles"; // The clouds are transformed back into the frame of the input topic, not frame_id. EXPECT_EQ(ground->back().header.frame_id, "lidar"); EXPECT_EQ(obstacles->back().header.frame_id, "lidar"); } TEST_F(ObstaclesDetectionTest, ProjectsObstaclesOntoTheGroundPlane) { // proj_obstacles is the obstacles cloud flattened to z=0, with flat surfaces removed. // Note it is published in frame_id, unlike ground/obstacles which keep the input frame. addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("wait_for_transform", 0.1)}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> proj = collect("proj_obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(proj->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, groundAndWall())); ASSERT_TRUE(spinUntil([&]() { return !proj->empty(); })); const sensor_msgs::msg::PointCloud2 & cloud = proj->back(); ASSERT_GT(cloud.width, 0u) << "the wall must survive as a projected obstacle"; EXPECT_EQ(cloud.header.frame_id, "base_link") << "proj_obstacles uses frame_id, not the input frame"; for(size_t i=0; i(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.2"))}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, groundAndWall())); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); EXPECT_GT(ground->back().width, 0u) << "the z=0 plane is below the 0.2 m threshold"; EXPECT_GT(obstacles->back().width, 0u) << "the wall rises above it"; } TEST_F(ObstaclesDetectionTest, MapFrameIdAloneDoesNotMoveTheHeightReference) { // The robot sits 1 m above the map origin, but Grid/MapFrameProjection is false by // default, so pose.z() is ignored and the heights stay relative to the base frame. // Setting map_frame_id on its own therefore changes nothing here. addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("map_frame_id", "map"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.2"))}))); publishStaticTf("base_link", "lidar"); publishStaticTf("map", "base_link", 0.0, 0.0, 1.0); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, groundAndWall())); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); EXPECT_GT(ground->back().width, 0u) << "without Grid/MapFrameProjection the map height is not applied"; } TEST_F(ObstaclesDetectionTest, MapFrameIdLevelsTheGroundUsingRollAndPitch) { // Only pose.z() is gated by Grid/MapFrameProjection: roll and pitch are always // applied. So map_frame_id on its own still levels the segmentation to the map's // horizontal, which is what matters when the robot is on a slope. const double pitch = 10.0 * M_PI / 180.0; // A plane that is level in the map frame, seen from a base frame pitched by 10 deg: // in the base frame it rises to well above the 0.1 m ground threshold. const std::vector plane = planeLevelInMapFrame(pitch); ASSERT_GT(plane.back().z, 0.1f) << "precondition: tilted beyond the threshold"; // Without a map frame the tilt is taken at face value: not ground. { addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.1"))}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, plane)); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); EXPECT_EQ(ground->back().width, 0u) << "a slope read in the base frame is not ground"; } } TEST_F(ObstaclesDetectionTest, MapFrameIdRecoversTheGroundOnASlope) { // Same tilted plane, but now the node knows the robot is pitched in the map frame, // so it levels the cloud and the slope becomes ground again. const double pitch = 10.0 * M_PI / 180.0; addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("map_frame_id", "map"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.1"))}))); publishStaticTf("base_link", "lidar"); publishStaticTfRPY("map", "base_link", 0.0, pitch, 0.0); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, planeLevelInMapFrame(pitch))); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); EXPECT_GT(ground->back().width, 0u) << "leveled by the map pitch, the slope is ground -- roll/pitch apply even " "though Grid/MapFrameProjection is false"; } TEST_F(ObstaclesDetectionTest, MapFrameProjectionSegmentsRelativeToTheMap) { // Same setup plus Grid/MapFrameProjection=true. Now pose.z() participates, the whole // cloud sits 1 m up in the map frame, and nothing is below the 0.2 m ground // threshold any more. addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("map_frame_id", "map"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MapFrameProjection", std::string("true")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.2"))}))); publishStaticTf("base_link", "lidar"); publishStaticTf("map", "base_link", 0.0, 0.0, 1.0); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, groundAndWall())); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); EXPECT_EQ(ground->back().width, 0u) << "lifted 1 m in the map frame, nothing is below the ground threshold"; EXPECT_GT(obstacles->back().width, 0u) << "everything becomes an obstacle instead"; } /// Runs the node with the given Grid range settings and returns the ground cloud. class ObstaclesDetectionRangeTest : public NodeTest { protected: sensor_msgs::msg::PointCloud2 groundWithRange( const std::string & rangeMin, const std::string & rangeMax, const std::vector & points) { addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({ rclcpp::Parameter("frame_id", "base_link"), rclcpp::Parameter("wait_for_transform", 0.1), rclcpp::Parameter("Grid/NormalsSegmentation", std::string("false")), rclcpp::Parameter("Grid/MaxGroundHeight", std::string("0.2")), rclcpp::Parameter("Grid/RangeMin", rangeMin), rclcpp::Parameter("Grid/RangeMax", rangeMax)}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); EXPECT_TRUE(waitForSubscriber(pub)); EXPECT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, points)); EXPECT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })); return ground->empty() ? sensor_msgs::msg::PointCloud2() : ground->back(); } }; TEST_F(ObstaclesDetectionRangeTest, RangeFilteringDisabledKeepsEverything) { // Grid/RangeMax=0 means no upper limit, so both patches survive. const sensor_msgs::msg::PointCloud2 ground = groundWithRange("0.0", "0.0", nearAndFarPatches()); EXPECT_EQ(ground.width, 50u); } TEST_F(ObstaclesDetectionRangeTest, GridRangeMaxDropsDistantPoints) { // Only the patch inside 1 m survives. const sensor_msgs::msg::PointCloud2 ground = groundWithRange("0.0", "1.0", nearAndFarPatches()); ASSERT_EQ(ground.width, 25u); const std::pair extent = xExtent(ground); EXPECT_NEAR(extent.first, 0.4f, 1e-3); EXPECT_LT(extent.second, 1.0f) << "nothing beyond the 1 m limit may remain"; } TEST_F(ObstaclesDetectionRangeTest, GridRangeMinDropsNearbyPoints) { // The mirror image: everything closer than 1 m is discarded instead. const sensor_msgs::msg::PointCloud2 ground = groundWithRange("1.0", "0.0", nearAndFarPatches()); ASSERT_EQ(ground.width, 25u); const std::pair extent = xExtent(ground); EXPECT_GT(extent.first, 1.0f) << "nothing closer than the 1 m limit may remain"; EXPECT_NEAR(extent.second, 3.1f, 1e-3); } TEST_F(ObstaclesDetectionRangeTest, DefaultRangeMaxIsFiveMeters) { // Grid/RangeMax defaults to 5.0, not infinity: a patch at 6 m is silently dropped // even though no range parameter was set. std::vector points = nearAndFarPatches(); for(int i=0; i<5; ++i) { for(int j=0; j<5; ++j) { points.push_back(cv::Point3f(5.9f + 0.05f*i, -0.1f + 0.05f*j, 0.0f)); } } const sensor_msgs::msg::PointCloud2 ground = groundWithRange("0.0", "5.0", points); // the defaults, stated explicitly EXPECT_EQ(ground.width, 50u) << "the 6 m patch is beyond the default range"; EXPECT_LT(xExtent(ground).second, 5.0f); } TEST_F(ObstaclesDetectionTest, PublishesEmptyCloudsForAnEmptyInput) { addNode(std::make_shared(rclcpp::NodeOptions() .parameter_overrides({rclcpp::Parameter("frame_id", "base_link")}))); publishStaticTf("base_link", "lidar"); std::shared_ptr> ground = collect("ground"); std::shared_ptr> obstacles = collect("obstacles"); rclcpp::Publisher::SharedPtr pub = helper()->create_publisher("cloud", 10); ASSERT_TRUE(waitForSubscriber(pub)); ASSERT_TRUE(waitForPublisher(ground->subscription)); pub->publish(makeXYZCloud("lidar", 1000.0, {})); ASSERT_TRUE(spinUntil([&]() { return !ground->empty() && !obstacles->empty(); })) << "an empty input must still produce output, not a dropped message"; EXPECT_EQ(ground->back().width, 0u); EXPECT_EQ(obstacles->back().width, 0u); }