/* 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 #include #include #include #include #include #include #include #include #if defined(WITH_OCTOMAP_MSGS) and defined(RTABMAP_OCTOMAP) #include #endif #if defined(WITH_GRID_MAP_ROS) and defined(RTABMAP_GRIDMAP) #include #endif using namespace rtabmap_util_test; namespace { ::testing::Environment * const kEnv = registerRclcppEnvironment(); constexpr float kCellSize = 0.05f; /// Anything at or below this height is ground, anything above it an obstacle. constexpr float kGroundHeight = 0.1f; /// The height of node 2's obstacle, which is what puts it on the obstacle side. constexpr float kObstacleHeight = 0.5f; /** * @brief A node carrying a ready-made local occupancy grid. * * MapsManager only regenerates a local grid when the sensor data has none * (`gridCellSize() == 0`). Handing it the cells directly keeps the assembled map exactly * predictable, instead of depending on how a depth image or scan would be segmented. * * @param cells coordinates in the node's own frame; the pose is applied when assembling. */ rtabmap::Signature makeGridSignature( int id, const rtabmap::Transform & pose, const std::vector & ground, const std::vector & obstacles, const std::vector & empty = {}) { auto toMat = [](const std::vector & points) { if(points.empty()) { return cv::Mat(); } cv::Mat mat(1, int(points.size()), CV_32FC3); for(size_t i=0; i(0, int(i)) = cv::Vec3f(points[i].x, points[i].y, points[i].z); } return mat; }; rtabmap::SensorData data; data.setId(id); data.setStamp(1000.0 + id); data.setOccupancyGrid(toMat(ground), toMat(obstacles), toMat(empty), kCellSize, cv::Point3f(0, 0, 0)); rtabmap::Signature s(id, /*mapId=*/0, /*weight=*/1, data.stamp(), /*label=*/"", pose, rtabmap::Transform(), data); return s; } /** * @brief A node carrying a raw laser scan, which MapsManager has to segment itself. * * This is the other half of updateMapCaches(): when the sensor data has no local grid it * builds one with LocalGridMaker instead of just caching the cells. With the parameters * in MapsManagerTest::sceneParameters() the segmentation is a plain height passthrough, * so which points come back as ground and which as obstacles is decided by their z alone. */ rtabmap::Signature makeScanSignature( int id, const rtabmap::Transform & pose, const std::vector & points) { cv::Mat scan(1, int(points.size()), CV_32FC3); for(size_t i=0; i(0, int(i)) = cv::Vec3f(points[i].x, points[i].y, points[i].z); } rtabmap::SensorData data; data.setId(id); data.setStamp(1000.0 + id); data.setLaserScan(rtabmap::LaserScan(scan, /*maxPoints=*/0, /*maxRange=*/0.0f, rtabmap::LaserScan::kXYZ)); return rtabmap::Signature(id, /*mapId=*/0, /*weight=*/1, data.stamp(), /*label=*/"", pose, rtabmap::Transform(), data); } /// Reads point @p index of an XYZRGB cloud. cv::Point3f pointAt(const sensor_msgs::msg::PointCloud2 & cloud, size_t index) { uint32_t xo = 0, yo = 4, zo = 8; for(size_t i=0; i(base + xo), *reinterpret_cast(base + yo), *reinterpret_cast(base + zo)); } /// Reads the packed rgb field of point @p index as (r,g,b). cv::Vec3b colorAt(const sensor_msgs::msg::PointCloud2 & cloud, size_t index) { uint32_t offset = 16; for(size_t i=0; i> 16), uint8_t(packed >> 8), uint8_t(packed)); } /** * @brief The center of octomap voxel (@p i, @p j, @p k) at kCellSize resolution. * * Cells handed to the octomap have to sit on voxel centers when their neighbors matter: * a coordinate on a voxel boundary (a multiple of the cell size) falls on either side * depending on rounding, so a cell meant to touch its neighbor may not. */ cv::Point3f voxelCenter(int i, int j, int k) { return cv::Point3f((float(i)+0.5f)*kCellSize, (float(j)+0.5f)*kCellSize, (float(k)+0.5f)*kCellSize); } /// Where OctoMap::createCloud() reports the voxel centerd at @p center: x and y at the /// cell corner, z at the center. cv::Point3f asReported(const cv::Point3f & center) { return cv::Point3f(center.x - 0.5f*kCellSize, center.y - 0.5f*kCellSize, center.z); } /// True if @p cloud holds a point within @p tolerance of @p expected. bool containsPoint(const sensor_msgs::msg::PointCloud2 & cloud, const cv::Point3f & expected, float tolerance = 1e-3f) { for(size_t i=0; i= int(map.info.width) || row >= int(map.info.height)) { return -2; } return map.data[size_t(row) * map.info.width + col]; } /** * @brief True if any cell within @p radius cells of (@p x, @p y) holds @p value. * * Used for the octomap grid, which is discretized on OctoMap's own voxel lattice: the * cell containing a given point can sit a column away from where the same point lands in * the occupancy grid, and pinning that offset would be testing octomap's internals. */ bool hasValueNear(const nav_msgs::msg::OccupancyGrid & map, double x, double y, int8_t value, int radius = 1) { const int col = int((x - map.info.origin.position.x) / map.info.resolution); const int row = int((y - map.info.origin.position.y) / map.info.resolution); for(int r=row-radius; r<=row+radius; ++r) { for(int c=col-radius; c<=col+radius; ++c) { if(r >= 0 && c >= 0 && r < int(map.info.height) && c < int(map.info.width) && map.data[size_t(r) * map.info.width + c] == value) { return true; } } } return false; } /// How many cells of @p map hold @p value. int countCells(const nav_msgs::msg::OccupancyGrid & map, int8_t value) { int count = 0; for(size_t i=0; i & overrides = {}, const rtabmap::ParametersMap & rtabmapParameters = rtabmap::ParametersMap()) { // Each test gets its own namespace: MapsManager reports whether anyone is // listening, and a subscription from a previous test in this process can still // be winding down on the shared topic names. static int counter = 0; namespace_ = uFormat("/maps_manager_test_%d", ++counter); node_ = addNode(std::make_shared("maps_manager_test", namespace_, rclcpp::NodeOptions().parameter_overrides(overrides))); maps_ = std::make_shared(); maps_->init(*node_, "test", true); rtabmap::ParametersMap parameters = sceneParameters(); for(rtabmap::ParametersMap::const_iterator iter=rtabmapParameters.begin(); iter!=rtabmapParameters.end(); ++iter) { parameters[iter->first] = iter->second; } maps_->setParameters(parameters); } /// The fully qualified name of one of MapsManager's topics. std::string topic(const std::string & name) const { return namespace_ + "/" + name; } /** * @brief The two-node scene every geometric assertion below is written against. * * @note The cells span both axes on purpose. An occupancy grid is a 2D map, and * OccupancyGrid::assemble() deliberately builds nothing from a scene that is * only a line of cells, so a fixture laid out along a single axis would give * an empty grid with working clouds. * @note Node 1 also carries empty cells, which is what the octomap reports as free * space; they do not reach the ground/obstacle clouds. * @note The two nodes deliberately arrive differently: node 1 with a ready-made local * grid, node 2 with a raw scan MapsManager has to segment itself. Both branches * of updateMapCaches() are therefore exercised by every test below. */ std::map scene() { std::map signatures; signatures.insert(std::make_pair(1, makeGridSignature(1, poseOf(1), {cv::Point3f(0.5f, -0.1f, 0.0f), cv::Point3f(0.5f, 0.1f, 0.0f)}, {cv::Point3f(1.0f, 0.0f, 0.0f)}, {cv::Point3f(0.2f, -0.1f, 0.0f), cv::Point3f(0.2f, 0.1f, 0.0f)}))); // Node 2 hands over the raw scan instead, so MapsManager has to segment it: the // point at ground height becomes a ground cell, the raised one an obstacle. signatures.insert(std::make_pair(2, makeScanSignature(2, poseOf(2), {cv::Point3f(0.5f, 0.1f, 0.0f), cv::Point3f(1.0f, -0.1f, kObstacleHeight)}))); return signatures; } static rtabmap::Transform poseOf(int id) { return rtabmap::Transform(2.0f * float(id - 1), 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); } static std::map posesOfScene() { std::map poses; poses.insert(std::make_pair(1, poseOf(1))); poses.insert(std::make_pair(2, poseOf(2))); return poses; } /// Feeds the scene in and publishes it, then spins so the messages arrive. void updateAndPublish(bool updateGrid = true, bool updateOctomap = false) { const std::map signatures = scene(); const std::map poses = posesOfScene(); maps_->updateMapCaches(poses, /*memory=*/0, updateGrid, updateOctomap, signatures); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(100)); } /// Feeds the scene in with the octomap updated, then publishes. void updateAndPublishOctomap() { const std::map poses = posesOfScene(); maps_->updateMapCaches(poses, /*memory=*/0, /*updateGrid=*/false, /*updateOctomap=*/true, scene()); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); } /// Subscribes and waits until MapsManager has seen the subscription. template std::shared_ptr> collectFromMaps(const std::string & name) { std::shared_ptr> collector = collect(topic(name)); EXPECT_TRUE(waitForPublisher(collector->subscription)) << "no publisher on " << topic(name); EXPECT_TRUE(spinUntil([&]() { return maps_->hasSubscribers(); })) << "MapsManager never saw the subscription on " << topic(name); return collector; } std::string namespace_; rclcpp::Node::SharedPtr node_; std::shared_ptr maps_; }; //============================================================================ // Assembled clouds //============================================================================ TEST_F(MapsManagerTest, AssemblesGroundAndObstacleClouds) { start(); std::shared_ptr> ground = collectFromMaps("cloud_ground"); std::shared_ptr> obstacles = collectFromMaps("cloud_obstacles"); updateAndPublish(); ASSERT_FALSE(ground->empty()) << "no ground cloud published"; ASSERT_FALSE(obstacles->empty()) << "no obstacle cloud published"; EXPECT_EQ(ground->back().header.frame_id, "map"); EXPECT_EQ(ground->back().width * ground->back().height, 3u) << "two ground cells from node 1 and one from node 2"; EXPECT_EQ(obstacles->back().width * obstacles->back().height, 2u); // The cells are stored in each node's own frame and placed by its pose. EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(1.0f, 0.0f, 0.0f))) << "node 1 sits at the origin"; EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(3.0f, -0.1f, kObstacleHeight))) << "node 2 sits 2 m along x, so its obstacle lands at 3 m"; } TEST_F(MapsManagerTest, ColorsGroundGreenAndObstaclesRed) { start(); std::shared_ptr> ground = collectFromMaps("cloud_ground"); std::shared_ptr> obstacles = collectFromMaps("cloud_obstacles"); updateAndPublish(); ASSERT_FALSE(ground->empty()); ASSERT_FALSE(obstacles->empty()); EXPECT_EQ(colorAt(ground->back(), 0), cv::Vec3b(0, 255, 0)); EXPECT_EQ(colorAt(obstacles->back(), 0), cv::Vec3b(255, 0, 0)); } TEST_F(MapsManagerTest, CloudMapCombinesGroundAndObstacles) { start(); std::shared_ptr> cloudMap = collectFromMaps("cloud_map"); updateAndPublish(); ASSERT_FALSE(cloudMap->empty()) << "no cloud map published"; EXPECT_EQ(cloudMap->back().width * cloudMap->back().height, 5u) << "three ground cells plus two obstacles"; EXPECT_TRUE(containsPoint(cloudMap->back(), cv::Point3f(1.0f, 0.0f, 0.0f))); EXPECT_TRUE(containsPoint(cloudMap->back(), cv::Point3f(2.5f, 0.1f, 0.0f))) << "node 2's ground cell"; } TEST_F(MapsManagerTest, RegeneratesLocalGridsFromARawScan) { // The branch of updateMapCaches() where the sensor data has no local grid, so // LocalGridMaker builds one. Two scan points, split by height alone. start(); std::shared_ptr> ground = collectFromMaps("cloud_ground"); std::shared_ptr> obstacles = collectFromMaps("cloud_obstacles"); std::map signatures; signatures.insert(std::make_pair(1, makeScanSignature(1, rtabmap::Transform::getIdentity(), {cv::Point3f(0.5f, -0.1f, 0.0f), cv::Point3f(0.5f, 0.1f, kObstacleHeight)}))); std::map poses; poses.insert(std::make_pair(1, rtabmap::Transform::getIdentity())); maps_->updateMapCaches(poses, /*memory=*/0, true, false, signatures); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); ASSERT_FALSE(ground->empty()) << "no ground cloud published"; ASSERT_FALSE(obstacles->empty()) << "no obstacle cloud published"; EXPECT_EQ(ground->back().width * ground->back().height, 1u) << "the point at ground height"; EXPECT_EQ(obstacles->back().width * obstacles->back().height, 1u) << "the raised point"; // The cells are snapped to the grid, so they land within a cell of the scan points. EXPECT_TRUE(containsPoint(ground->back(), cv::Point3f(0.5f, -0.1f, 0.0f), kCellSize)); EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(0.5f, 0.1f, kObstacleHeight), kCellSize)); } TEST_F(MapsManagerTest, TheGroundHeightDecidesWhatIsAnObstacle) { // Same scan, but with the threshold lifted above the raised point: it is ground now, // which is what shows the height passthrough is doing the segmenting. rtabmap::ParametersMap parameters; parameters.insert(rtabmap::ParametersPair( rtabmap::Parameters::kGridMaxGroundHeight(), "1.0")); start({}, parameters); std::shared_ptr> ground = collectFromMaps("cloud_ground"); std::shared_ptr> obstacles = collectFromMaps("cloud_obstacles"); std::map signatures; signatures.insert(std::make_pair(1, makeScanSignature(1, rtabmap::Transform::getIdentity(), {cv::Point3f(0.5f, -0.1f, 0.0f), cv::Point3f(0.5f, 0.1f, kObstacleHeight)}))); std::map poses; poses.insert(std::make_pair(1, rtabmap::Transform::getIdentity())); maps_->updateMapCaches(poses, /*memory=*/0, true, false, signatures); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); ASSERT_FALSE(ground->empty()); ASSERT_FALSE(obstacles->empty()); EXPECT_EQ(ground->back().width * ground->back().height, 2u) << "both points are below the raised threshold"; EXPECT_EQ(obstacles->back().width * obstacles->back().height, 0u); } //============================================================================ // Occupancy grid //============================================================================ TEST_F(MapsManagerTest, PublishesTheOccupancyGrid) { start(); std::shared_ptr> grid = collectFromMaps("map"); updateAndPublish(); ASSERT_FALSE(grid->empty()) << "no occupancy grid published"; const nav_msgs::msg::OccupancyGrid & map = grid->back(); EXPECT_EQ(map.header.frame_id, "map"); EXPECT_NEAR(map.info.resolution, kCellSize, 1e-6); EXPECT_GT(map.info.width, 0u); // The map must agree with what getGridMap() hands out. float xMin = 0.0f, yMin = 0.0f, cellSize = 0.0f; const cv::Mat pixels = maps_->getGridMap(xMin, yMin, cellSize); EXPECT_NEAR(map.info.origin.position.x, xMin, 1e-6); EXPECT_NEAR(map.info.origin.position.y, yMin, 1e-6); EXPECT_NEAR(cellSize, kCellSize, 1e-6); EXPECT_EQ(map.info.width, uint32_t(pixels.cols)); EXPECT_EQ(map.info.height, uint32_t(pixels.rows)); // Obstacles are occupied, ground is free. EXPECT_EQ(cellAt(map, 1.0, 0.0), 100) << "node 1's obstacle"; EXPECT_EQ(cellAt(map, 3.0, -0.1), 100) << "node 2's obstacle"; EXPECT_EQ(cellAt(map, 0.5, -0.1), 0) << "node 1's ground"; } TEST_F(MapsManagerTest, CellSizeParameterChangesTheResolution) { rtabmap::ParametersMap parameters; parameters.insert(rtabmap::ParametersPair(rtabmap::Parameters::kGridCellSize(), "0.1")); start({}, parameters); float xMin = 0.0f, yMin = 0.0f, cellSize = 0.0f; maps_->getGridMap(xMin, yMin, cellSize); EXPECT_NEAR(cellSize, 0.1f, 1e-6) << "setParameters must reach the occupancy grid"; } TEST_F(MapsManagerTest, GridProbMapUsesProbabilities) { start(); std::shared_ptr> grid = collectFromMaps("grid_prob_map"); updateAndPublish(); ASSERT_FALSE(grid->empty()) << "no probability grid published"; EXPECT_NEAR(grid->back().info.resolution, kCellSize, 1e-6); // The probability map reports 0..100 instead of the ternary free/occupied/unknown. EXPECT_GT(cellAt(grid->back(), 1.0, 0.0), 50) << "the obstacle cell is likely occupied"; } //============================================================================ // Poses //============================================================================ TEST_F(MapsManagerTest, KeepsEveryPoseWithoutAFilterRadius) { start(); std::map poses; for(int id=1; id<=4; ++id) { poses.insert(std::make_pair(id, rtabmap::Transform(0.1f*float(id), 0, 0, 0, 0, 0))); } EXPECT_EQ(maps_->getFilteredPoses(poses).size(), poses.size()) << "map_filter_radius defaults to 0, which disables the filter"; } TEST_F(MapsManagerTest, FilterRadiusThinsNearbyPoses) { start({rclcpp::Parameter("map_filter_radius", 1.0)}); std::map poses; for(int id=1; id<=4; ++id) { // All within a meter of each other, and all facing the same way. poses.insert(std::make_pair(id, rtabmap::Transform(0.1f*float(id), 0, 0, 0, 0, 0))); } EXPECT_LT(maps_->getFilteredPoses(poses).size(), poses.size()) << "poses closer than the radius must be dropped"; EXPECT_GE(maps_->getFilteredPoses(poses).size(), 1u); } TEST_F(MapsManagerTest, DropsTheLatestPoseUnlessAlwaysUpdating) { // Pose 0 is the "current" node, not yet in the graph. It is only mapped when // map_always_update is set, otherwise the map only shows committed nodes. start({rclcpp::Parameter("map_empty_ray_tracing", false)}); std::map signatures = scene(); signatures.insert(std::make_pair(0, makeGridSignature(0, rtabmap::Transform(), {}, {cv::Point3f(9.0f, 0.0f, 0.0f)}))); std::map poses = posesOfScene(); poses.insert(std::make_pair(0, rtabmap::Transform::getIdentity())); const std::map filtered = maps_->updateMapCaches(poses, 0, true, false, signatures); EXPECT_EQ(filtered.find(0), filtered.end()) << "node 0 must be dropped by default"; EXPECT_EQ(filtered.size(), 2u); } TEST_F(MapsManagerTest, KeepsTheLatestPoseWhenAlwaysUpdating) { start({rclcpp::Parameter("map_always_update", true), rclcpp::Parameter("map_empty_ray_tracing", false)}); std::map signatures = scene(); signatures.insert(std::make_pair(0, makeGridSignature(0, rtabmap::Transform(), {}, {cv::Point3f(9.0f, 0.0f, 0.0f)}))); std::map poses = posesOfScene(); poses.insert(std::make_pair(0, rtabmap::Transform::getIdentity())); const std::map filtered = maps_->updateMapCaches(poses, 0, true, false, signatures); EXPECT_NE(filtered.find(0), filtered.end()) << "node 0 must be kept"; EXPECT_EQ(filtered.size(), 3u); } TEST_F(MapsManagerTest, IgnoresLandmarkPoses) { // Landmarks use negative ids and have no grid to contribute. start(); std::map poses; poses.insert(std::make_pair(-5, rtabmap::Transform::getIdentity())); poses.insert(std::make_pair(1, poseOf(1))); poses.insert(std::make_pair(2, poseOf(2))); const std::map filtered = maps_->updateMapCaches(poses, 0, true, false, scene()); EXPECT_EQ(filtered.find(-5), filtered.end()); EXPECT_EQ(filtered.size(), 2u); } TEST_F(MapsManagerTest, RefusesEmptyPoses) { start(); EXPECT_TRUE(maps_->updateMapCaches(std::map(), 0, true, false, scene()).empty()); } TEST_F(MapsManagerTest, RefusesWithoutMemoryOrSignatures) { start(); EXPECT_TRUE(maps_->updateMapCaches(posesOfScene(), 0, true, false, std::map()).empty()); } //============================================================================ // Subscriber bookkeeping //============================================================================ TEST_F(MapsManagerTest, HasNoSubscribersOnItsOwn) { start(); spinFor(std::chrono::milliseconds(100)); EXPECT_FALSE(maps_->hasSubscribers()); } TEST_F(MapsManagerTest, HasSubscribersOnceSomeoneListens) { start(); collectFromMaps("cloud_map"); EXPECT_TRUE(maps_->hasSubscribers()); } TEST_F(MapsManagerTest, AssumesTheMapChangedWithoutGridSubscribers) { // Whether the map changed is only known from OccupancyGrid::update(), which is only // run when someone wants a grid. With nobody listening the answer is assumed true. start(); spinFor(std::chrono::milliseconds(100)); EXPECT_TRUE(maps_->isMapUpdated()); } TEST_F(MapsManagerTest, ReportsTheMapUnchangedOnASecondIdenticalUpdate) { start(); collectFromMaps("map"); maps_->updateMapCaches(posesOfScene(), 0, true, false, scene()); EXPECT_TRUE(maps_->isMapUpdated()) << "the first update adds both nodes"; maps_->updateMapCaches(posesOfScene(), 0, true, false, scene()); EXPECT_FALSE(maps_->isMapUpdated()) << "nothing moved and nothing was added"; } TEST_F(MapsManagerTest, PublishesNothingWithoutSubscribers) { start(); maps_->updateMapCaches(posesOfScene(), 0, true, false, scene()); maps_->publishMaps(posesOfScene(), node_->now(), "map"); // Subscribing afterwards with a volatile subscription sees nothing. std::shared_ptr> cloud = collect(topic("cloud_map")); spinFor(std::chrono::milliseconds(200)); EXPECT_TRUE(cloud->empty()); } //============================================================================ // Latching //============================================================================ TEST_F(MapsManagerTest, LatchesTheMapForLateSubscribers) { start(); // latch defaults to true EXPECT_TRUE(maps_->isLatching()); collectFromMaps("map"); updateAndPublish(); // A subscriber joining after the fact still gets the last map, because the publisher // is transient local. std::shared_ptr> late = collect(topic("map"), rclcpp::QoS(1).reliable().transient_local()); EXPECT_TRUE(spinUntil([&]() { return !late->empty(); })) << "the latched map was not delivered"; } TEST_F(MapsManagerTest, DoesNotLatchWhenLatchIsFalse) { start({rclcpp::Parameter("latch", false)}); EXPECT_FALSE(maps_->isLatching()); collectFromMaps("map"); updateAndPublish(); // With a volatile publisher there is no history to hand out (and a transient local // subscription is not even compatible), so a late subscriber gets nothing. std::shared_ptr> late = collect(topic("map"), rclcpp::QoS(1).reliable().transient_local()); spinFor(std::chrono::milliseconds(300)); EXPECT_TRUE(late->empty()); } //============================================================================ // Caches //============================================================================ TEST_F(MapsManagerTest, ClearEmptiesTheAssembledClouds) { start(); std::shared_ptr> cloud = collectFromMaps("cloud_map"); updateAndPublish(); ASSERT_FALSE(cloud->empty()); ASSERT_GT(cloud->back().width * cloud->back().height, 0u); maps_->clear(); maps_->publishMaps(posesOfScene(), node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); EXPECT_EQ(cloud->back().width * cloud->back().height, 0u) << "clear() must drop the cached grids, leaving nothing to assemble"; } TEST_F(MapsManagerTest, Set2DMapInstallsAGridDirectly) { // Used when a map comes back from the database rather than from local grids. start(); cv::Mat map(4, 6, CV_8SC1, cv::Scalar(-1)); map.at(2, 3) = 100; map.at(1, 1) = 0; // The poses are not optional: set2DMap() keeps the map only when it is told which // nodes it was assembled from. maps_->set2DMap(map, /*xMin=*/-1.0f, /*yMin=*/-0.5f, kCellSize, posesOfScene()); float xMin = 0.0f, yMin = 0.0f, cellSize = 0.0f; const cv::Mat out = maps_->getGridMap(xMin, yMin, cellSize); ASSERT_FALSE(out.empty()); EXPECT_EQ(out.cols, 6); EXPECT_EQ(out.rows, 4); EXPECT_NEAR(xMin, -1.0f, 1e-6); EXPECT_NEAR(yMin, -0.5f, 1e-6); EXPECT_NEAR(cellSize, kCellSize, 1e-6); EXPECT_EQ(out.at(2, 3), 100); EXPECT_EQ(out.at(1, 1), 0); } //============================================================================ // Parameters that moved to the rtabmap library //============================================================================ TEST_F(MapsManagerTest, Set2DMapNeedsThePosesTheMapCameFrom) { // The grid is kept only together with the poses it was assembled from, so that it // knows which nodes are already in it. Without them the map is dropped, and // MapsManager warns rather than leaving the caller to wonder. start(); cv::Mat map(4, 6, CV_8SC1, cv::Scalar(-1)); map.at(2, 3) = 100; maps_->set2DMap(map, -1.0f, -0.5f, kCellSize, std::map()); float xMin = 0.0f, yMin = 0.0f, cellSize = 0.0f; EXPECT_TRUE(maps_->getGridMap(xMin, yMin, cellSize).empty()); } TEST_F(MapsManagerTest, CopiesMovedParametersToTheirNewNames) { start(); node_->declare_parameter("grid_cell_size", 0.1); node_->declare_parameter("proj_max_ground_height", 0.3); rtabmap::ParametersMap parameters; maps_->backwardCompatibilityParameters(*node_, parameters); ASSERT_TRUE(parameters.find(rtabmap::Parameters::kGridCellSize()) != parameters.end()) << "grid_cell_size must be copied to " << rtabmap::Parameters::kGridCellSize(); EXPECT_NEAR(uStr2Float(parameters.at(rtabmap::Parameters::kGridCellSize())), 0.1f, 1e-6); ASSERT_TRUE(parameters.find(rtabmap::Parameters::kGridMaxGroundHeight()) != parameters.end()); EXPECT_NEAR(uStr2Float(parameters.at(rtabmap::Parameters::kGridMaxGroundHeight())), 0.3f, 1e-6); } TEST_F(MapsManagerTest, LeavesUnsetLegacyParametersAlone) { start(); rtabmap::ParametersMap parameters; maps_->backwardCompatibilityParameters(*node_, parameters); EXPECT_TRUE(parameters.empty()) << "nothing was declared, so nothing should be copied"; } //============================================================================ // Octomap //============================================================================ #if defined(WITH_OCTOMAP_MSGS) and defined(RTABMAP_OCTOMAP) TEST_F(MapsManagerTest, PublishesTheBinaryOctomap) { start(); std::shared_ptr> binary = collectFromMaps("octomap_binary"); updateAndPublishOctomap(); ASSERT_FALSE(binary->empty()) << "no binary octomap published"; EXPECT_EQ(binary->back().header.frame_id, "map"); EXPECT_TRUE(binary->back().binary); EXPECT_EQ(binary->back().id, "ColorOcTree") << "rtabmap keeps a color per voxel, so the tree type is not a plain OcTree"; EXPECT_NEAR(binary->back().resolution, kCellSize, 1e-6); EXPECT_FALSE(binary->back().data.empty()) << "the serialized tree must not be empty"; } TEST_F(MapsManagerTest, PublishesTheFullOctomap) { start(); std::shared_ptr> full = collectFromMaps("octomap_full"); updateAndPublishOctomap(); ASSERT_FALSE(full->empty()) << "no full octomap published"; EXPECT_FALSE(full->back().binary) << "the full tree carries occupancy probabilities"; EXPECT_EQ(full->back().id, "ColorOcTree") << "consumers deserialize on this id, so both messages must report the same type"; EXPECT_NEAR(full->back().resolution, kCellSize, 1e-6); EXPECT_FALSE(full->back().data.empty()); } TEST_F(MapsManagerTest, PublishesTheOctomapOccupiedSpace) { start(); std::shared_ptr> occupied = collectFromMaps("octomap_occupied_space"); updateAndPublishOctomap(); ASSERT_FALSE(occupied->empty()) << "no octomap cloud published"; EXPECT_EQ(occupied->back().header.frame_id, "map"); EXPECT_EQ(occupied->back().width * occupied->back().height, 5u) << "occupied space is the obstacles plus the ground: 2 + 3 cells"; } TEST_F(MapsManagerTest, PublishesTheOctomapObstacles) { start(); std::shared_ptr> obstacles = collectFromMaps("octomap_obstacles"); updateAndPublishOctomap(); ASSERT_FALSE(obstacles->empty()) << "no octomap obstacles published"; EXPECT_EQ(obstacles->back().width * obstacles->back().height, 2u); // Points come back at voxel centers, up to half a cell from where they went in. EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(1.0f, 0.0f, 0.0f), kCellSize)); EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(3.0f, -0.1f, kObstacleHeight), kCellSize)); } TEST_F(MapsManagerTest, PublishesTheOctomapGround) { start(); std::shared_ptr> ground = collectFromMaps("octomap_ground"); updateAndPublishOctomap(); ASSERT_FALSE(ground->empty()) << "no octomap ground published"; EXPECT_EQ(ground->back().width * ground->back().height, 3u) << "the ground cells only, not the empty ones"; EXPECT_TRUE(containsPoint(ground->back(), cv::Point3f(0.5f, -0.1f, 0.0f), kCellSize)); } TEST_F(MapsManagerTest, PublishesTheOctomapEmptySpace) { start(); std::shared_ptr> empty = collectFromMaps("octomap_empty_space"); updateAndPublishOctomap(); ASSERT_FALSE(empty->empty()) << "no octomap empty space published"; EXPECT_EQ(empty->back().header.frame_id, "map"); EXPECT_EQ(empty->back().width * empty->back().height, 2u) << "node 1's two empty cells, and nothing else: ground cells are stored as " "occupied nodes flagged as ground, so they are not free space"; // createCloud() reports x and y at the cell corner but z at the cell center. EXPECT_TRUE(containsPoint(empty->back(), cv::Point3f(0.2f, -0.1f, 0.5f*kCellSize), 1e-3f)); EXPECT_TRUE(containsPoint(empty->back(), cv::Point3f(0.2f, 0.1f, 0.5f*kCellSize), 1e-3f)); } TEST_F(MapsManagerTest, PublishesTheOctomapFrontier) { // A frontier cell is a free cell with at least one unknown face neighbor. Nothing // encloses this scene, so the frontier is exactly the free space: node 1's two empty // cells. The ground and obstacle cells are occupied nodes and never qualify. start(); std::shared_ptr> frontier = collectFromMaps("octomap_global_frontier_space"); updateAndPublishOctomap(); ASSERT_FALSE(frontier->empty()) << "no octomap frontier published"; EXPECT_EQ(frontier->back().header.frame_id, "map"); EXPECT_EQ(frontier->back().width * frontier->back().height, 2u); EXPECT_TRUE(containsPoint(frontier->back(), cv::Point3f(0.2f, -0.1f, 0.5f*kCellSize), 1e-3f)); EXPECT_TRUE(containsPoint(frontier->back(), cv::Point3f(0.2f, 0.1f, 0.5f*kCellSize), 1e-3f)); } TEST_F(MapsManagerTest, AnEnclosedEmptyCellIsNotAFrontier) { // The frontier rule in one scene: two identical empty cells, one walled in on all six // faces by obstacles and one out in the open. Both are free space, but only the open // one has an unknown neighbor, so only it is a frontier. start(); std::shared_ptr> frontier = collectFromMaps("octomap_global_frontier_space"); std::shared_ptr> empty = collect(topic("octomap_empty_space")); ASSERT_TRUE(waitForPublisher(empty->subscription)); const cv::Point3f enclosed = voxelCenter(19, 0, 9); const cv::Point3f open = voxelCenter(39, 0, 9); std::map signatures; signatures.insert(std::make_pair(1, makeGridSignature(1, rtabmap::Transform::getIdentity(), /*ground=*/{}, /*obstacles=*/{voxelCenter(18, 0, 9), voxelCenter(20, 0, 9), // -x, +x voxelCenter(19, -1, 9), voxelCenter(19, 1, 9), // -y, +y voxelCenter(19, 0, 8), voxelCenter(19, 0, 10)}, // -z, +z /*empty=*/{enclosed, open}))); std::map poses; poses.insert(std::make_pair(1, rtabmap::Transform::getIdentity())); maps_->updateMapCaches(poses, /*memory=*/0, /*updateGrid=*/false, /*updateOctomap=*/true, signatures); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(200)); ASSERT_FALSE(empty->empty()) << "no octomap empty space published"; ASSERT_FALSE(frontier->empty()) << "no octomap frontier published"; // Both cells are free space... EXPECT_EQ(empty->back().width * empty->back().height, 2u); EXPECT_TRUE(containsPoint(empty->back(), asReported(enclosed), 1e-3f)); EXPECT_TRUE(containsPoint(empty->back(), asReported(open), 1e-3f)); // ...but the walled-in one is not on the frontier. EXPECT_EQ(frontier->back().width * frontier->back().height, 1u); EXPECT_TRUE(containsPoint(frontier->back(), asReported(open), 1e-3f)) << "the open cell borders unknown space"; EXPECT_FALSE(containsPoint(frontier->back(), asReported(enclosed), 1e-3f)) << "all six face neighbors of the enclosed cell are known, so it is not a frontier"; } TEST_F(MapsManagerTest, PublishesTheOctomapGrid) { start(); std::shared_ptr> grid = collectFromMaps("octomap_grid"); updateAndPublishOctomap(); ASSERT_FALSE(grid->empty()) << "no octomap grid published"; const nav_msgs::msg::OccupancyGrid & map = grid->back(); EXPECT_EQ(map.header.frame_id, "map"); EXPECT_EQ(countCells(map, 100), 2) << "one occupied cell per obstacle"; EXPECT_TRUE(hasValueNear(map, 1.0, 0.0, 100)) << "node 1's obstacle"; EXPECT_TRUE(hasValueNear(map, 3.0, -0.1, 100)) << "node 2's obstacle"; EXPECT_TRUE(hasValueNear(map, 0.5, -0.1, 0)) << "node 1's ground is free space"; } TEST_F(MapsManagerTest, ExposesTheOctomap) { start(); ASSERT_NE(maps_->getOctomap(), nullptr); } #endif //============================================================================ // Elevation map (grid_map) //============================================================================ #if defined(WITH_GRID_MAP_ROS) and defined(RTABMAP_GRIDMAP) TEST_F(MapsManagerTest, PublishesTheElevationMap) { // updateMapCaches() has no explicit flag for the elevation map: it is only built // through the "nothing requested, so follow the subscribers" path. start(); std::shared_ptr> elevation = collectFromMaps("elevation_map"); const std::map poses = posesOfScene(); maps_->updateMapCaches(poses, /*memory=*/0, /*updateGrid=*/false, /*updateOctomap=*/false, scene()); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); ASSERT_FALSE(elevation->empty()) << "no elevation map published"; const grid_map_msgs::msg::GridMap & msg = elevation->back(); EXPECT_EQ(msg.header.frame_id, "map"); EXPECT_NEAR(msg.info.resolution, kCellSize, 1e-6); EXPECT_GT(msg.info.length_x, 0.0); EXPECT_GT(msg.info.length_y, 0.0); ASSERT_FALSE(msg.layers.empty()) << "the grid map must carry its layers"; EXPECT_NE(std::find(msg.layers.begin(), msg.layers.end(), "elevation"), msg.layers.end()) << "the elevation layer is what makes this an elevation map"; EXPECT_EQ(msg.data.size(), msg.layers.size()) << "one data matrix per layer"; } TEST_F(MapsManagerTest, DoesNotRepublishAnUnchangedElevationMap) { // Like every other map, once latched it should stay put until something changes. start(); std::shared_ptr> elevation = collectFromMaps("elevation_map"); const std::map poses = posesOfScene(); maps_->updateMapCaches(poses, 0, false, false, scene()); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); ASSERT_FALSE(elevation->empty()); const size_t afterFirst = elevation->size(); // Nothing new to assemble, so nothing to send. maps_->updateMapCaches(poses, 0, false, false, scene()); maps_->publishMaps(poses, node_->now(), "map"); spinFor(std::chrono::milliseconds(150)); EXPECT_EQ(elevation->size(), afterFirst) << "the latched elevation map was republished unchanged"; } #endif TEST_F(MapsManagerTest, ExposesTheOccupancyGridAndLocalMapMaker) { start(); ASSERT_NE(maps_->getOccupancyGrid(), nullptr); ASSERT_NE(maps_->getLocalMapMaker(), nullptr); EXPECT_NEAR(maps_->getOccupancyGrid()->getCellSize(), kCellSize, 1e-6); }